Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ OpenPI 把成熟 Coding Agent 的工作习惯做成 Pi-native 能力,但不复
| 统一配置 | `/openpi-setup` 管理 OpenPI 自有模型、并发、Footer、输出密度与 Post-edit 偏好 |
| 模型授权 | `/login google-antigravity`;实验性的 `/login cursor`(支持 Pi 工具,不执行 Cursor 原生工具) |

Code Mode 的终端视图由 OpenPI 通过 Pi 原生工具渲染接口提供:默认收起脚本与长参数,展示运行状态、子调用统计和短结果预览;失败与取消保留明显提示。`Ctrl+O` 展开脚本、子调用参数与原始输出。该呈现增强需要 Pi 提供 `registerToolRenderer`(已核对 Pi 1.0.2);旧版本保留原生显示。它不改变 Code Mode 执行、权限、模型可见结果或默认启用设置。
Code Mode 的终端视图由 OpenPI 通过 Pi 原生工具渲染接口提供:默认收起脚本、长参数、结果正文及完整输出路径,仅展示运行状态,并按原顺序逐条展示全部已记录的子调用摘要,不折叠调用数量;Bash 命令预览最多显示 80 个终端列,超出部分用省略号隐藏,窄终端会进一步截断;子调用沿用普通工具的线框图标,执行中只用灰色表示,不重复显示运行标记;失败与取消保留明显提示。`Ctrl+O` 展开脚本、子调用参数、原始输出与完整输出路径。该呈现增强需要 Pi 提供 `registerToolRenderer`(已核对 Pi 1.0.2);旧版本保留原生显示。它不改变 Code Mode 执行、权限、模型可见结果或默认启用设置。

OpenPI 采用 [MIT License](LICENSE);第三方来源与保留声明见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。

Expand Down
149 changes: 62 additions & 87 deletions extensions/codemode-display/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,15 @@ import {
} from "@earendil-works/pi-tui";
import type { TSchema } from "typebox";
import { sanitizeTerminalText } from "../shared/terminal-text.ts";
import { toolActivityIcon } from "../shared/tool-activity.ts";

export type CodemodeRenderers = Pick<
ToolDefinition<TSchema, unknown, unknown>,
"renderShell" | "renderCall" | "renderResult"
>;

const RECENT_CALLS = 4;
const SALIENT_CALLS = 2;
const OUTPUT_LINES = 5;
const BASH_HINT_COLUMNS = 80;
const JSON_LIMIT = 32_768;
const PREVIEW_CHARS = 4_096;
const HEADER =
/^Script (completed|failed)\nWall time (\d+(?:\.\d+)?) seconds\nOutput:\n$/u;

Expand Down Expand Up @@ -127,14 +125,27 @@ function framed(

/**
* The first top-level string argument (path, command, pattern…) identifies a
* call at a glance. Native previews are JSON truncated at 200 chars, so this
* reads only a complete leading string literal and otherwise shows nothing.
* call at a glance. Pi truncates JSON previews to 197 chars plus "...".
* Retain the verified string prefix without inventing the missing suffix;
* a cut through an escape sequence drops only that incomplete escape.
*/
function argHint(args: unknown) {
const match = /^\{"[^"\\]*":("(?:[^"\\]|\\.)*")/u.exec(string(args));
if (!match) return "";
const raw = string(args);
const complete = /^\{"[^"\\]*":("(?:[^"\\]|\\.)*")/u.exec(raw);
try {
return inline(JSON.parse(match[1]!)).trim();
if (complete) return inline(JSON.parse(complete[1]!)).trim();
if (raw.length !== 200 || !raw.endsWith("...")) return "";
const preview = raw.slice(0, -3);
const prefix =
/^\{"[^"\\]*":("(?:[^"\\\u0000-\u001f]|\\(?:["\\/bfnrt]|u[\da-f]{4}))*)/iu.exec(
preview,
);
if (!prefix) return "";
const remainder = preview.slice(prefix[0].length);
if (remainder && !/^\\(?:u[\da-f]{0,3})?$/iu.test(remainder)) return "";
const decoded: string = JSON.parse(`${prefix[1]}"`);
// A truncated surrogate pair must not leave a broken terminal glyph.
return `${inline(decoded.replace(/[\ud800-\udbff]$/u, "")).trimEnd()}…`;
} catch {
return "";
}
Expand All @@ -148,10 +159,14 @@ function callLine(
) {
const status = callStatus(call);
const name = expanded ? safe(call.name) : inline(call.name);
const toolIcon = expanded ? "✓" : toolActivityIcon(string(call.name));
const label =
toolIcon === "✓" ? name : `${name.charAt(0).toUpperCase()}${name.slice(1)}`;
const quiet = !expanded && status === "running";
const meta: string[] = [];
if (string(call.error))
meta.push(expanded ? "error" : `error: ${inline(call.error)}`);
else if (status !== "ok") meta.push(status);
else if (status !== "ok" && !quiet) meta.push(status);
const time = duration(call.durationMs);
if (time) meta.push(time);
if (
Expand All @@ -161,10 +176,18 @@ function callLine(
)
meta.push(`$${call.cost.toPrecision(3)}`);
const tail = meta.length ? ` · ${meta.join(" · ")}` : "";
let line = `${theme.fg(ICON_COLOR[status], ICON[status])} ${theme.fg(status === "error" ? "error" : "toolTitle", name)}`;
const toolPrefix = toolIcon === "✓" ? "" : `${theme.fg("dim", toolIcon)} `;
const statusPrefix = quiet
? " "
: `${theme.fg(ICON_COLOR[status], ICON[status])} `;
let line = `${statusPrefix}${toolPrefix}${theme.fg(status === "error" ? "error" : quiet ? "muted" : "toolTitle", label)}`;
// The hint yields its width to status, error and timing facts.
const hint = expanded ? "" : argHint(call.args);
const budget = width - visibleWidth(line) - visibleWidth(tail) - 1;
const available = width - visibleWidth(line) - visibleWidth(tail) - 1;
const budget =
string(call.name).toLowerCase() === "bash"
? Math.min(available, BASH_HINT_COLUMNS)
: available;
if (hint && budget >= 4)
line += ` ${theme.fg("muted", truncateToWidth(hint, budget, "…"))}`;
if (tail) line += theme.fg("dim", tail);
Expand Down Expand Up @@ -212,7 +235,6 @@ function resultComponent(
const output = (knownHeader ? result.content.slice(1) : result.content)
.filter((item) => item.type === "text")
.map((item) => item.text);
const projections = output.map(outputProjection);
const images = result.content.filter((item) => item.type === "image").length;
const failed = isError || knownHeader?.[1] === "failed";
const outcome = partial
Expand Down Expand Up @@ -242,14 +264,19 @@ function resultComponent(
finished: [ICON.unknown, "warning"],
} as const;
const [icon, iconColor] = outcomeIcon[outcome];
const outcomeText = `${theme.fg(iconColor, outcome)}${theme.fg("muted", ` · ${facts.join(" · ")}`)}`;
const statusLine = `${theme.fg(iconColor, icon)} ${outcomeText}`;
const quiet = partial && !expanded;
const iconText = quiet ? "" : theme.fg(iconColor, icon);
const outcomeText = quiet
? theme.fg("muted", facts.join(" · "))
: `${theme.fg(iconColor, outcome)}${theme.fg("muted", ` · ${facts.join(" · ")}`)}`;
const statusLine = `${iconText ? `${iconText} ` : ""}${outcomeText}`;
// A call header rendered from the same row state carries the status line.
const merged = state?.codemodeHeader === true;
if (state) {
state.codemodeTone = tone;
state.codemodeResult = true;
state.codemodeIcon = theme.fg(iconColor, icon);
state.codemodeIcon = iconText;
state.codemodePending = quiet;
state.codemodeOutcome = outcomeText;
}
const outputColor = failed ? "error" : "toolOutput";
Expand All @@ -276,8 +303,8 @@ function resultComponent(
...wrapped("(no recorded nested calls)", theme, width, "muted"),
);
rows.push(...wrapped("Output", theme, width, "muted"));
output.forEach((raw, index) => {
const projection = projections[index]!;
output.forEach((raw) => {
const projection = outputProjection(raw);
if (projection.decoded) {
rows.push(
...wrapped(
Expand Down Expand Up @@ -316,67 +343,10 @@ function resultComponent(
const compactRows = (width: number) => {
const rows = merged ? [] : [statusLine];
const indent = width > 16 ? " " : "";
const issue = (call: Record<string, unknown>) =>
["error", "cancelled", "unknown"].includes(callStatus(call));
const recentStart = Math.max(0, calls.length - RECENT_CALLS);
const salient = calls
.slice(0, recentStart)
.filter(issue)
.slice(-SALIENT_CALLS);
const recent = calls.slice(recentStart);
const hidden = calls.length - salient.length - recent.length;
// Nested outcomes are not the outer script's status. A total row keeps
// any issue that the bounded call list cannot show visible.
const shown = [...salient, ...recent];
for (const status of ["error", "cancelled", "unknown"] as const) {
const count = counts[status];
if (count > shown.filter((call) => callStatus(call) === status).length)
rows.push(
theme.fg(
ICON_COLOR[status],
`${ICON[status]} ${count} nested ${status}`,
),
);
}
const line = (call: Record<string, unknown>) =>
indent + callLine(call, false, theme, width - indent.length);
rows.push(...salient.map(line));
if (hidden)
rows.push(
theme.fg(
"dim",
`${indent}⋯ ${hidden} more ${hidden === 1 ? "call" : "calls"}`,
),
);
rows.push(...recent.map(line));
let more = "";
if (partial) rows.push(theme.fg("muted", "Output pending"));
else if (output.length) {
const joined = projections
.map((projection) => projection.text)
.join("\n");
// Blank lines spend the preview budget without showing anything.
const visible = joined
.slice(0, PREVIEW_CHARS)
.split("\n")
.filter((row) => row.trim())
.join("\n");
// A gutter separates script output from the call list above it.
const gutter = width > 16 ? theme.fg("dim", "│ ") : "";
const lines = wrapped(
safe(visible),
theme,
width - visibleWidth(gutter),
outputColor,
);
rows.push(...lines.slice(0, OUTPUT_LINES).map((row) => gutter + row));
if (joined.length > PREVIEW_CHARS) more = "… more output · ";
else if (lines.length > OUTPUT_LINES) {
const count = lines.length - OUTPUT_LINES;
more = `… ${count} more ${count === 1 ? "line" : "lines"} · `;
}
}
rows.push(theme.fg("dim", more) + expandHint());
for (const call of calls)
rows.push(indent + callLine(call, false, theme, width - indent.length));
// Output is evidence for the expanded view, not part of the activity list.
rows.push(expandHint());
return rows.map((row) => truncateToWidth(row, width, ""));
};

Expand All @@ -396,14 +366,15 @@ function resultComponent(
"",
),
);
if (string(details?.fullOutputPath)) {
const path = `Full output: ${expanded ? safe(details?.fullOutputPath) : inline(details?.fullOutputPath)}`;
if (expanded && string(details?.fullOutputPath))
rows.push(
...(expanded
? wrapped(path, theme, width, "muted")
: [truncateToWidth(theme.fg("muted", path), width, "")]),
...wrapped(
`Full output: ${safe(details?.fullOutputPath)}`,
theme,
width,
"muted",
),
);
}
return rows;
},
{ top: false, bottom: () => true },
Expand All @@ -425,11 +396,15 @@ export const codemodeRenderers: CodemodeRenderers = {
? "invalid script argument"
: "receiving script";
if (state) state.codemodeHeader = true;
const name = theme.fg("toolTitle", theme.bold("codemode"));
const title = () => {
const name = theme.fg(
state?.codemodePending ? "muted" : "toolTitle",
theme.bold("codemode"),
);
const outcome = string(state?.codemodeOutcome);
const icon = string(state?.codemodeIcon);
return outcome
? `${string(state?.codemodeIcon)} ${name} ${outcome}${theme.fg("muted", ` · ${detail}`)}`
? `${icon ? `${icon} ` : ""}${name} ${outcome}${theme.fg("muted", ` · ${detail}`)}`
: `${name}${theme.fg("muted", ` · ${detail}`)}`;
};
return framed(
Expand Down
4 changes: 2 additions & 2 deletions extensions/shared/tool-activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ function pendingVerb(name: string) {
}
}

function activityIcon(name: string) {
export function toolActivityIcon(name: string) {
switch (canonicalName(name)) {
case "read":
return "\ueaa4";
Expand Down Expand Up @@ -297,7 +297,7 @@ export function toolActivityText(
}
if (duration) parts.push(theme.fg("dim", duration));
const detail = parts.join(theme.fg("dim", " · "));
return `${theme.fg("dim", activityIcon(activity.name))} ${verb} ${theme.fg("muted", row.target)}${detail ? ` ${detail}` : ""}`;
return `${theme.fg("dim", toolActivityIcon(activity.name))} ${verb} ${theme.fg("muted", row.target)}${detail ? ` ${detail}` : ""}`;
}

export function renderToolActivityLine(
Expand Down
53 changes: 47 additions & 6 deletions tests/extensions/codemode-display/native.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ test("native loader selects the public renderer seam while retaining native Code
{ type: "text", text: output },
];
const details = {
fullOutputPath: "/tmp/codemode-output-evidence.txt",
calls: [
{
id: "1",
Expand All @@ -159,20 +160,56 @@ test("native loader selects the public renderer seam while retaining native Code
},
...Array.from({ length: 6 }, (_, index) => ({
id: String(index + 3),
name: "read",
args: "{}",
name: index === 5 ? "bash" : "read",
args:
index === 5
? `${JSON.stringify({ command: `git status --short; ${"echo long; ".repeat(40)}` }).slice(0, 197)}...`
: "{}",
status: "ok",
durationMs: 2,
})),
],
};
component.updateResult(
{
content,
details: {
...details,
calls: details.calls.map((call) => ({
...call,
status: "running",
error: undefined,
})),
},
isError: false,
},
true,
);
const pending = component
.render(300)
.map(stripTerminalSequences)
.join("\n");
assert.doesNotMatch(
pending,
/^\s*…|codemode running| · running|more calls?/mu,
);
assert.match(pending, /earlier-read example\.ts/u);
assert.match(pending, /earlier-bash long shell/u);
assert.match(pending, /Bash git status --short.*… · 2ms/u);
component.updateResult({ content, details, isError: false }, false);
const compact = component.render(80).map(stripTerminalSequences).join("\n");
assert.match(compact, /codemode · 2 script lines/u);
assert.match(
compact,
/codemode completed · 1\.7s · 8 calls · 2 script lines/u,
);
assert.doesNotMatch(compact, /very long shell command/u);
assert.match(compact, /✗ earlier-read · error: file missing/u);
assert.match(compact, /⊘ earlier-bash · cancelled/u);
assert.doesNotMatch(compact, /\\n/u);
assert.match(compact, /✗ earlier-read example\.ts · error: file missing/u);
assert.match(compact, /⊘ earlier-bash long shell · cancelled/u);
assert.match(compact, /Bash git status --short.*… · 2ms/u);
assert.doesNotMatch(
compact,
/\\n|first output line|Full output:|codemode-output-evidence/u,
);
const artifacts = process.env.OPENPI_CODEMODE_RENDER_ARTIFACT_DIR;
if (artifacts) await mkdir(artifacts, { recursive: true });
for (const width of [80, 40]) {
Expand All @@ -192,6 +229,10 @@ test("native loader selects the public renderer seam while retaining native Code
assert.match(expanded, /Calls/u);
assert.match(expanded, /Output/u);
assert.match(expanded, /Raw output/u);
assert.match(expanded, /first output line/u);
assert.match(expanded, /Full output:/u);
assert.doesNotMatch(collapsed, /first output line|Full output:/u);
assert.match(collapsed, /Bash git status/u);
if (artifacts) {
await writeFile(
path.join(artifacts, `compact-${width}.txt`),
Expand Down
Loading
Loading