pi-firecode 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -41
- package/README.zh-CN.md +74 -0
- package/config.example.jsonc +21 -22
- package/dist/config.example.jsonc +90 -0
- package/dist/index.js +11464 -0
- package/dist/master/prompts/master.en.md +22 -0
- package/{master → dist/master}/prompts/master.zh.md +3 -3
- package/dist/master/prompts/worker.en.md +1 -0
- package/dist/watcher/prompts/watch.en.md +43 -0
- package/package.json +10 -27
- package/activity.ts +0 -92
- package/busy.ts +0 -188
- package/config.ts +0 -500
- package/deliver.ts +0 -119
- package/flame.ts +0 -149
- package/format.ts +0 -133
- package/header.ts +0 -218
- package/herdr-client.ts +0 -60
- package/index.ts +0 -68
- package/jsonc.ts +0 -34
- package/master/actions.ts +0 -293
- package/master/activity-list.ts +0 -281
- package/master/event-card.ts +0 -87
- package/master/event-format.ts +0 -83
- package/master/guard.ts +0 -46
- package/master/index.ts +0 -252
- package/master/list-view.ts +0 -129
- package/master/outbox.ts +0 -188
- package/master/prompt.ts +0 -26
- package/master/role.ts +0 -18
- package/master/run.ts +0 -315
- package/master/runtime.ts +0 -259
- package/master/spawn.ts +0 -234
- package/master/state.ts +0 -225
- package/master/worker-view.ts +0 -517
- package/provider/claude-sub.ts +0 -151
- package/provider/openai-native/index.ts +0 -8
- package/provider/openai-native/src/compact-client.ts +0 -350
- package/provider/openai-native/src/config.ts +0 -297
- package/provider/openai-native/src/extension.ts +0 -93
- package/provider/openai-native/src/native-compaction.ts +0 -151
- package/provider/openai-native/src/native-details.ts +0 -157
- package/provider/openai-native/src/native-replay.ts +0 -253
- package/provider/openai-native/src/native-runtime.ts +0 -144
- package/provider/openai-native/src/options.ts +0 -87
- package/provider/openai-native/src/request-pipeline.ts +0 -21
- package/provider/openai-native/src/responses-input.ts +0 -433
- package/review/advisor.ts +0 -110
- package/review/card.ts +0 -398
- package/review/checkpoint.ts +0 -378
- package/review/evidence.ts +0 -230
- package/review/index.ts +0 -992
- package/review/occupancy.ts +0 -25
- package/review/outcome.ts +0 -98
- package/review/prompt.ts +0 -209
- package/review/reviewer.ts +0 -353
- package/review/session.ts +0 -110
- package/review/state.ts +0 -877
- package/review/ui.ts +0 -89
- package/round-recorder.ts +0 -18
- package/session/herdr-display.ts +0 -87
- package/session/presets.ts +0 -300
- package/session/quota.ts +0 -123
- package/session/rename.ts +0 -46
- package/session/stats.ts +0 -313
- package/statusbar/index.ts +0 -256
- package/statusbar/render.ts +0 -113
- package/theme.ts +0 -45
- package/tools/actions.ts +0 -53
- package/tools/assistant-view.ts +0 -64
- package/tools/click-anchor.ts +0 -56
- package/tools/group-view.ts +0 -420
- package/tools/grouping.ts +0 -158
- package/tools/host.ts +0 -369
- package/tools/index.ts +0 -186
- package/tools/line.ts +0 -231
- package/tools/machine.ts +0 -78
- package/tools/parts.ts +0 -143
- package/tools/round.ts +0 -95
- package/tools/timing.ts +0 -22
- package/tools/turn-clock.ts +0 -41
- package/tools/turn-summary.ts +0 -109
- package/truncated-write.ts +0 -18
- package/watcher/card.ts +0 -91
- package/watcher/index.ts +0 -176
- package/watcher/observer.ts +0 -75
- package/watcher/transcript.ts +0 -56
- /package/{master → dist/master}/prompts/worker.zh.md +0 -0
- /package/{review → dist/review}/prompts/advisor.en.md +0 -0
- /package/{review → dist/review}/prompts/advisor.zh.md +0 -0
- /package/{review → dist/review}/prompts/review.en.md +0 -0
- /package/{review → dist/review}/prompts/review.zh.md +0 -0
- /package/{watcher → dist/watcher}/prompts/watch.zh.md +0 -0
package/theme.ts
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
/** 数值分级配色:所有阈值到主题色的映射集中在此;品牌火焰色在 flame.ts。 */
|
|
2
|
-
import type { ExtensionAPI, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
|
|
4
|
-
/** 阈值表:首个满足 `value >= at` 的档位胜出,表按 at 降序书写。 */
|
|
5
|
-
type Threshold = { at: number; color: ThemeColor };
|
|
6
|
-
|
|
7
|
-
function pick(value: number, thresholds: readonly Threshold[]): ThemeColor {
|
|
8
|
-
for (const threshold of thresholds) {
|
|
9
|
-
if (value >= threshold.at) return threshold.color;
|
|
10
|
-
}
|
|
11
|
-
return thresholds[thresholds.length - 1].color;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const CONTEXT_FILL = [
|
|
15
|
-
{ at: 75, color: "error" },
|
|
16
|
-
{ at: 50, color: "warning" },
|
|
17
|
-
{ at: 0, color: "dim" },
|
|
18
|
-
] as const satisfies readonly Threshold[];
|
|
19
|
-
|
|
20
|
-
const RESULT_SIZE = [
|
|
21
|
-
{ at: 50_000, color: "error" },
|
|
22
|
-
{ at: 10_000, color: "warning" },
|
|
23
|
-
{ at: 0, color: "dim" },
|
|
24
|
-
] as const satisfies readonly Threshold[];
|
|
25
|
-
|
|
26
|
-
export const contextColor = (percent: number | null | undefined): ThemeColor =>
|
|
27
|
-
percent == null ? "muted" : pick(percent, CONTEXT_FILL);
|
|
28
|
-
|
|
29
|
-
export const sizeColor = (chars: number): ThemeColor =>
|
|
30
|
-
pick(chars, RESULT_SIZE);
|
|
31
|
-
|
|
32
|
-
type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
|
|
33
|
-
|
|
34
|
-
const THINKING: Record<ThinkingLevel, ThemeColor> = {
|
|
35
|
-
off: "thinkingOff",
|
|
36
|
-
minimal: "thinkingMinimal",
|
|
37
|
-
low: "thinkingLow",
|
|
38
|
-
medium: "thinkingMedium",
|
|
39
|
-
high: "thinkingHigh",
|
|
40
|
-
xhigh: "thinkingXhigh",
|
|
41
|
-
max: "thinkingMax",
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
export const thinkingColor = (level: ThinkingLevel): ThemeColor =>
|
|
45
|
-
THINKING[level];
|
package/tools/actions.ts
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
/** 内置工具的动作词与目标:工具行渲染与 Master 活动列表共用的唯一来源。 */
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import type { ClipSide } from "../format.js";
|
|
4
|
-
import { commandParts, genericArgsParts, pathValue, type Part } from "./parts.js";
|
|
5
|
-
|
|
6
|
-
export const LABEL = { read: "读取", bash: "操作", edit: "修改", write: "写入" } as const;
|
|
7
|
-
|
|
8
|
-
export type ToolArgs = {
|
|
9
|
-
path?: string;
|
|
10
|
-
file_path?: string;
|
|
11
|
-
offset?: number;
|
|
12
|
-
limit?: number;
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
const argPath = (args: ToolArgs): string => args?.file_path ?? args?.path ?? "";
|
|
16
|
-
|
|
17
|
-
/** read 的 offset/limit → `:12-40`、`:12+`。 */
|
|
18
|
-
function rangeSuffix(args: ToolArgs): string {
|
|
19
|
-
if (args.offset === undefined && args.limit === undefined) return "";
|
|
20
|
-
const start = args.offset ?? 1;
|
|
21
|
-
if (args.limit === undefined) return `:${start}+`;
|
|
22
|
-
return `:${start}-${start + args.limit - 1}`;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const CD_PREFIX = /^\s*cd\s+(?:"([^"]*)"|'([^']*)'|(\S+))\s*&&\s*/u;
|
|
26
|
-
const trimSlash = (path: string) => path.replace(/(?<=.)\/+$/u, "");
|
|
27
|
-
|
|
28
|
-
/** agent 的命令常以“cd 到当前工作目录 &&”开头,这段前缀没有信息量(同路径显示 ./ 的思路);cd 到别处保留。 */
|
|
29
|
-
function withoutCwdPrefix(command: string, cwd: string): string {
|
|
30
|
-
const match = CD_PREFIX.exec(command);
|
|
31
|
-
if (!match || !cwd) return command;
|
|
32
|
-
const target = (match[1] ?? match[2] ?? match[3]).replace(/^~(?=\/|$)/u, homedir());
|
|
33
|
-
return trimSlash(target) === trimSlash(cwd) ? command.slice(match[0].length) : command;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** 工具调用的目标片段与过长时的保留侧(路径留尾、命令留头)。 */
|
|
37
|
-
export function toolTarget(tool: string, args: unknown, cwd: string): { value: Part[]; clip: ClipSide } {
|
|
38
|
-
const input = (args ?? {}) as ToolArgs & { command?: string };
|
|
39
|
-
switch (tool) {
|
|
40
|
-
case "read": return { value: pathValue(argPath(input), cwd, rangeSuffix(input)), clip: "start" };
|
|
41
|
-
case "edit":
|
|
42
|
-
case "write": return { value: pathValue(argPath(input), cwd), clip: "start" };
|
|
43
|
-
case "bash": return { value: commandParts(withoutCwdPrefix(input.command ?? "", cwd)), clip: "end" };
|
|
44
|
-
default: return { value: genericArgsParts(args), clip: "end" };
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** 一句纯文本动作:动作词 + 目标,如“修改 ./tools/line.ts”;未内置的工具用工具名作动作词。 */
|
|
49
|
-
export function toolActionText(tool: string, args: unknown, cwd: string): string {
|
|
50
|
-
const word = LABEL[tool as keyof typeof LABEL] ?? tool;
|
|
51
|
-
const target = toolTarget(tool, args, cwd).value.map((part) => part.text).join("");
|
|
52
|
-
return target ? `${word} ${target}` : word;
|
|
53
|
-
}
|
package/tools/assistant-view.ts
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import type { AssistantMessageComponent } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { Container, MouseRegion, Spacer, type Component } from "@earendil-works/pi-tui";
|
|
3
|
-
import { textOf } from "../format.js";
|
|
4
|
-
import { assistantFacts } from "./host.js";
|
|
5
|
-
|
|
6
|
-
export type AssistantActivity = "thinking" | "replying";
|
|
7
|
-
type AssistantData = ReturnType<typeof assistantFacts>;
|
|
8
|
-
|
|
9
|
-
/** 流式中的助手在做什么:最后一块是有字的正文即在回复,其余(还没内容、思考、空块)都算思考。 */
|
|
10
|
-
function activity(data: AssistantData): AssistantActivity | undefined {
|
|
11
|
-
if (!data.isStreaming || ["aborted", "error", "length"].includes(data.lastMessage?.stopReason ?? "")) return;
|
|
12
|
-
const last = data.lastMessage?.content.at(-1);
|
|
13
|
-
if (last?.type === "text" && last.text.trim()) return "replying";
|
|
14
|
-
if (!last || last.type === "text" || last.type === "thinking") return "thinking";
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function withoutThinking(children: readonly Component[]): Component[] {
|
|
18
|
-
const visible: Component[] = [];
|
|
19
|
-
for (const child of children) {
|
|
20
|
-
if (child instanceof MouseRegion) continue;
|
|
21
|
-
if (child instanceof Spacer && visible.at(-1) instanceof Spacer) continue;
|
|
22
|
-
visible.push(child);
|
|
23
|
-
}
|
|
24
|
-
while (visible.at(-1) instanceof Spacer) visible.pop();
|
|
25
|
-
return visible;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** 宿主在 contentContainer 内只给思考块包 MouseRegion。 */
|
|
29
|
-
export function hasThinking(source: AssistantMessageComponent): boolean {
|
|
30
|
-
return assistantFacts(source).contentContainer.children.some((child) => child instanceof MouseRegion);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** 正文和错误复用原组件;折叠态藏起思考块。 */
|
|
34
|
-
export function assistantView(source: AssistantMessageComponent, expanded: boolean): {
|
|
35
|
-
body?: Component;
|
|
36
|
-
activity?: AssistantActivity;
|
|
37
|
-
} {
|
|
38
|
-
const data = assistantFacts(source);
|
|
39
|
-
const children = data.contentContainer.children;
|
|
40
|
-
const thinking = hasThinking(source);
|
|
41
|
-
const visible = expanded || !thinking ? children : withoutThinking(children);
|
|
42
|
-
const hasBody = visible.some((child) => !(child instanceof Spacer))
|
|
43
|
-
|| source.children.some((child) => child !== data.contentContainer);
|
|
44
|
-
let body: Component | undefined;
|
|
45
|
-
if (hasBody) {
|
|
46
|
-
body = source;
|
|
47
|
-
if (!expanded && thinking) {
|
|
48
|
-
const content = new Container();
|
|
49
|
-
content.children = visible;
|
|
50
|
-
// 独立的渲染接收者保留原生 OSC133/鼠标布局逻辑;原消息、原树和思考展开状态不改写。
|
|
51
|
-
const projection: AssistantMessageComponent = Object.create(source);
|
|
52
|
-
projection.children = source.children.map((child) => child === data.contentContainer ? content : child);
|
|
53
|
-
projection.invalidate = () => {};
|
|
54
|
-
body = projection;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
return { body, activity: expanded ? undefined : activity(data) };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** 助手消息里的正文文字(不含思考与工具调用),中间回复的一行预览用它取首句。 */
|
|
61
|
-
export function replyText(source: AssistantMessageComponent): string {
|
|
62
|
-
const message = assistantFacts(source).lastMessage;
|
|
63
|
-
return textOf(message?.content).trim();
|
|
64
|
-
}
|
package/tools/click-anchor.ts
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
/** 锚定要的滚动视口:首行、是否跟随末尾、视口高、上次布局的内容总高,以及停在某行与恢复跟随。 */
|
|
2
|
-
export interface Scroller {
|
|
3
|
-
readonly top: number;
|
|
4
|
-
readonly following: boolean;
|
|
5
|
-
readonly viewport: number;
|
|
6
|
-
readonly contentHeight: number;
|
|
7
|
-
holdAt(top: number): void;
|
|
8
|
-
follow(): void;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* 点击展开/收起时的视口锚定,主会话与子代理视图共用。被锚定的组件位于滚动内容的末尾(主会话的聊天区、视图的正文),
|
|
13
|
-
* 展开只改变被点行之下的内容。原本跟随末尾的点击只作用于这一次布局变化:展开后被点行仍在视口里就继续跟随;
|
|
14
|
-
* 会被顶出视口时把它停在视口顶行,露出尽量多的展开内容,此后内容高度再变(新输出到达)即恢复跟随。
|
|
15
|
-
* 停住期间用户自己滚动过(首行变了、或滚回末尾)即放弃锚定;原本已上滚的点击不碰视口。
|
|
16
|
-
*/
|
|
17
|
-
export class ClickAnchor {
|
|
18
|
-
private height = 0;
|
|
19
|
-
/** 等这次点击的布局:被点行在滚动内容里的行号。 */
|
|
20
|
-
private pending: number | undefined;
|
|
21
|
-
private held: { top: number; height: number } | undefined;
|
|
22
|
-
|
|
23
|
-
constructor(private readonly scroller: () => Scroller | undefined) {}
|
|
24
|
-
|
|
25
|
-
/** 点击改了展开状态时调用;y 是被点行在组件上次布局里的行号。 */
|
|
26
|
-
click(y: number): void {
|
|
27
|
-
const scroller = this.scroller();
|
|
28
|
-
if (!scroller || (!scroller.following && !this.held)) return;
|
|
29
|
-
this.pending = scroller.contentHeight - (this.height - y);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** 每次渲染出组件、视口按新高度定位之前调用。 */
|
|
33
|
-
layout(height: number): void {
|
|
34
|
-
const growth = height - this.height;
|
|
35
|
-
this.height = height;
|
|
36
|
-
const scroller = this.scroller();
|
|
37
|
-
if (!scroller) return;
|
|
38
|
-
const line = this.pending;
|
|
39
|
-
this.pending = undefined;
|
|
40
|
-
if (line !== undefined) {
|
|
41
|
-
this.held = undefined;
|
|
42
|
-
if (line >= scroller.contentHeight + growth - scroller.viewport) return scroller.follow();
|
|
43
|
-
scroller.holdAt(line);
|
|
44
|
-
// 宿主按上次布局的内容高夹住首行(内容原本不足一屏时夹到 0):记实际停住的位置,否则下一次布局会误判成用户滚动过。
|
|
45
|
-
this.held = { top: scroller.top, height };
|
|
46
|
-
return;
|
|
47
|
-
}
|
|
48
|
-
const held = this.held;
|
|
49
|
-
if (!held) return;
|
|
50
|
-
if (scroller.following || scroller.top !== held.top) this.held = undefined;
|
|
51
|
-
else if (height !== held.height) {
|
|
52
|
-
this.held = undefined;
|
|
53
|
-
scroller.follow();
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|
package/tools/group-view.ts
DELETED
|
@@ -1,420 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
AssistantMessageComponent,
|
|
3
|
-
CustomMessageComponent,
|
|
4
|
-
getMarkdownTheme,
|
|
5
|
-
ToolExecutionComponent,
|
|
6
|
-
UserMessageComponent,
|
|
7
|
-
type ExtensionUIContext,
|
|
8
|
-
type Theme,
|
|
9
|
-
} from "@earendil-works/pi-coding-agent";
|
|
10
|
-
import { Markdown, Spacer, Text, type Component, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
11
|
-
import { stripVTControlCharacters } from "node:util";
|
|
12
|
-
import { parseEnvelopes } from "../deliver.js";
|
|
13
|
-
import { HEAT_COLORS, paint, settling } from "../flame.js";
|
|
14
|
-
import { toolTarget } from "./actions.js";
|
|
15
|
-
import { firstSentence, oneLine, textOf } from "../format.js";
|
|
16
|
-
import { ToolLine, resultText, type ActionLine } from "./line.js";
|
|
17
|
-
import { genericArgsParts } from "./parts.js";
|
|
18
|
-
import { assistantView, hasThinking, replyText, type AssistantActivity } from "./assistant-view.js";
|
|
19
|
-
import { customMessageOf, isEntry, isToolOutputEcho, openCard, textComponentText, toolFacts, userTextOf, type ToolFacts, type ToolRow } from "./host.js";
|
|
20
|
-
import { machineEntries, machineLine, type MachineEntry } from "./machine.js";
|
|
21
|
-
import { combineRounds, type Round, roundOf } from "./round.js";
|
|
22
|
-
import { ARRIVAL_FLASH_MS, type TurnClock } from "./turn-clock.js";
|
|
23
|
-
import { Line, TurnSummary, type SummaryView } from "./turn-summary.js";
|
|
24
|
-
|
|
25
|
-
type RowData = ToolFacts;
|
|
26
|
-
const rowData = toolFacts;
|
|
27
|
-
|
|
28
|
-
function actionLine(component: Component | undefined): ActionLine | undefined {
|
|
29
|
-
return component && "actionWord" in component && typeof component.actionWord === "string"
|
|
30
|
-
? component as ActionLine : undefined;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** 机器消息:整条文本由信封构成,来自 CustomMessage 或空闲时投递的用户消息。 */
|
|
34
|
-
function machineText(component: Component): string | undefined {
|
|
35
|
-
if (component instanceof CustomMessageComponent) {
|
|
36
|
-
return textOf(customMessageOf(component).content);
|
|
37
|
-
}
|
|
38
|
-
if (component instanceof UserMessageComponent) return userTextOf(component);
|
|
39
|
-
return undefined;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function machineEntriesOf(component: Component): MachineEntry[] | undefined {
|
|
43
|
-
const text = machineText(component);
|
|
44
|
-
if (text === undefined) return undefined;
|
|
45
|
-
const details = component instanceof CustomMessageComponent
|
|
46
|
-
? customMessageOf(component).details
|
|
47
|
-
: undefined;
|
|
48
|
-
return machineEntries(text, details);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** 整条内容由信封构成的 CustomMessage 或用户消息。 */
|
|
52
|
-
export function isMachineMessage(component: Component): boolean {
|
|
53
|
-
return machineEntriesOf(component) !== undefined;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function machineBodies(component: Component): string {
|
|
57
|
-
return parseEnvelopes(machineText(component) ?? "")?.map((envelope) => envelope.body).join("\n\n") ?? "";
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** 只有人类用户消息是轮次边界。 */
|
|
61
|
-
function isHuman(component: Component): boolean {
|
|
62
|
-
return component instanceof UserMessageComponent && !machineEntriesOf(component);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** 模型侧的过程 = 模型的输出、动作与收件(含机器消息);投影里再加上本段的补话。其余节点(错误、CustomEntry 等)是段边界。 */
|
|
66
|
-
function isModelProcess(component: Component): boolean {
|
|
67
|
-
return component instanceof ToolExecutionComponent
|
|
68
|
-
|| component instanceof AssistantMessageComponent
|
|
69
|
-
|| component instanceof CustomMessageComponent
|
|
70
|
-
|| (component instanceof UserMessageComponent && !isHuman(component));
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** 有实质的过程(工具或思考)或轮记录才有摘要行;提示只折入有摘要行的段。 */
|
|
74
|
-
function hasSubstance(component: Component): boolean {
|
|
75
|
-
return component instanceof ToolExecutionComponent
|
|
76
|
-
|| (component instanceof AssistantMessageComponent && hasThinking(component))
|
|
77
|
-
|| roundOf(component) !== undefined;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* 宿主把 transcript 提示(缓存/丢思考/压缩计费)与状态行(切档、切模型)画成整行单色 Text:
|
|
82
|
-
* warning 是提示,dim 是状态。颜色是宿主唯一给出的语义通道;错误与混色文本仍是边界。
|
|
83
|
-
*/
|
|
84
|
-
function noticeKind(component: Component | undefined, theme: Theme): "warning" | "dim" | undefined {
|
|
85
|
-
if (!(component instanceof Text)) return;
|
|
86
|
-
const text = textComponentText(component);
|
|
87
|
-
const plain = stripVTControlCharacters(text);
|
|
88
|
-
return (["warning", "dim"] as const).find((color) => theme.fg(color, plain) === text);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function compactLine(row: RowData | undefined, theme: Theme): ToolLine {
|
|
92
|
-
return new ToolLine({
|
|
93
|
-
label: row ? row.toolDefinition?.label ?? row.toolName : "思考", value: genericArgsParts(row?.args), clip: "end", theme,
|
|
94
|
-
ctx: {
|
|
95
|
-
state: { ...row?.rendererState, errorText: row?.result?.isError ? resultText(row.result, true).displayText : "" },
|
|
96
|
-
cwd: row?.cwd ?? "", toolCallId: row?.toolCallId ?? "",
|
|
97
|
-
isPartial: row?.isPartial ?? false, isError: row?.result?.isError ?? false, expanded: false,
|
|
98
|
-
},
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** 折叠态每组最多列几条中间回复首句。 */
|
|
103
|
-
const REPLY_LINES = 3;
|
|
104
|
-
const ACTIVITY_TEXT = { thinking: "思考中", replying: "回复中" } as const satisfies Record<AssistantActivity, string>;
|
|
105
|
-
|
|
106
|
-
type Facts = Pick<SummaryView, "notice" | "action" | "arrival" | "failures" | "earlier"> & { round?: Round };
|
|
107
|
-
|
|
108
|
-
/** 一遍扫描段内过程,汇出摘要行需要的全部事实。 */
|
|
109
|
-
function scan(segment: readonly Component[], activity: AssistantActivity | undefined, env: ProjectionEnv): Facts {
|
|
110
|
-
const running: RowData[] = [];
|
|
111
|
-
let notice: string | undefined;
|
|
112
|
-
let arrival: Facts["arrival"];
|
|
113
|
-
const rounds: Round[] = [];
|
|
114
|
-
const failed = new Set<string>();
|
|
115
|
-
for (const item of segment) {
|
|
116
|
-
// 多条宿主提示只取首条原文,其余在展开态可见。
|
|
117
|
-
if (noticeKind(item, env.ui.theme) === "warning") notice ??= oneLine(stripVTControlCharacters(textComponentText(item as Text)));
|
|
118
|
-
const round = roundOf(item);
|
|
119
|
-
if (round) rounds.push(round);
|
|
120
|
-
const entries = machineEntriesOf(item);
|
|
121
|
-
for (const entry of entries ?? []) if (entry.failed && entry.worker) failed.add(entry.worker);
|
|
122
|
-
const settled = entries?.findLast((entry) => entry.duration);
|
|
123
|
-
const age = settled ? env.clock.arrivalAge(item) : Infinity;
|
|
124
|
-
if (settled && age < ARRIVAL_FLASH_MS) arrival = { text: settled.title, failed: settled.alarm, age };
|
|
125
|
-
if (item instanceof ToolExecutionComponent && rowData(item).isPartial) running.push(rowData(item));
|
|
126
|
-
}
|
|
127
|
-
const record = combineRounds(rounds);
|
|
128
|
-
return { notice, arrival, round: record?.round, earlier: record?.earlier, failures: failed.size, action: actionOf(running.at(-1), activity, env) };
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* 当前动作只说正在发生的事:运行中的工具 > 助手在思考/回复 > 指挥官回合在跑但两头都没动静(等模型)是思考中;
|
|
133
|
-
* 指挥官自己歇着、只在等子代理时没有当前动作,摘要行只留火苗(等待状态与计时只在边框)。
|
|
134
|
-
*/
|
|
135
|
-
function actionOf(tool: RowData | undefined, activity: AssistantActivity | undefined, env: ProjectionEnv): SummaryView["action"] {
|
|
136
|
-
if (tool) {
|
|
137
|
-
const line = actionLine(tool.callRendererComponent);
|
|
138
|
-
return line
|
|
139
|
-
? { word: line.actionWord, target: line.actionTarget }
|
|
140
|
-
: { word: tool.toolDefinition?.label ?? tool.toolName, target: toolTarget(tool.toolName, tool.args, tool.cwd).value.map((part) => part.text).join("").trim() };
|
|
141
|
-
}
|
|
142
|
-
if (activity) return { word: ACTIVITY_TEXT[activity] };
|
|
143
|
-
return env.clock.agentRunning ? { word: ACTIVITY_TEXT.thinking } : undefined;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** 机器消息:展开态一行 ↳,点击切换完整正文(信封用户消息)或原生卡片(CustomMessage)。 */
|
|
147
|
-
class MachineItem implements Component {
|
|
148
|
-
private rows = 1;
|
|
149
|
-
constructor(
|
|
150
|
-
private readonly item: Component,
|
|
151
|
-
private readonly entries: readonly MachineEntry[],
|
|
152
|
-
private readonly env: ProjectionEnv,
|
|
153
|
-
) {}
|
|
154
|
-
invalidate(): void {}
|
|
155
|
-
render(width: number): string[] {
|
|
156
|
-
const rows = this.entries.map((entry) => machineLine(entry, this.env.ui.theme, width));
|
|
157
|
-
this.rows = rows.length;
|
|
158
|
-
return this.env.isOpen(this.item) ? [...rows, ...this.body(width)] : rows;
|
|
159
|
-
}
|
|
160
|
-
private body(width: number): string[] {
|
|
161
|
-
if (this.item instanceof CustomMessageComponent) {
|
|
162
|
-
openCard(this.item);
|
|
163
|
-
return this.item.render(width);
|
|
164
|
-
}
|
|
165
|
-
const bodies = machineBodies(this.item);
|
|
166
|
-
return new Markdown(bodies, 1, 0, getMarkdownTheme()).render(width);
|
|
167
|
-
}
|
|
168
|
-
handleMouse(event: TuiMouseEvent) {
|
|
169
|
-
if (event.y >= this.rows) {
|
|
170
|
-
const inner = this.item as { handleMouse?: (e: TuiMouseEvent) => { handled: boolean } | undefined };
|
|
171
|
-
if (!this.env.isOpen(this.item)) return undefined;
|
|
172
|
-
return inner.handleMouse?.({ ...event, y: event.y - this.rows, height: event.height - this.rows });
|
|
173
|
-
}
|
|
174
|
-
if (event.type !== "click" || event.button !== "left") return undefined;
|
|
175
|
-
this.env.toggleOpen(this.item);
|
|
176
|
-
return { handled: true };
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
class ToolItem implements Component {
|
|
181
|
-
private leadingRows = 0;
|
|
182
|
-
constructor(
|
|
183
|
-
private readonly row: ToolRow,
|
|
184
|
-
private readonly ui: ProjectionUI,
|
|
185
|
-
private readonly toggle: (row: ToolRow) => void,
|
|
186
|
-
) {}
|
|
187
|
-
invalidate(): void {}
|
|
188
|
-
render(width: number): string[] {
|
|
189
|
-
const data = rowData(this.row);
|
|
190
|
-
if (!data.expanded) {
|
|
191
|
-
const renderer = actionLine(data.callRendererComponent) ?? compactLine(data, this.ui.theme);
|
|
192
|
-
return renderer.render(width);
|
|
193
|
-
}
|
|
194
|
-
const lines = this.row.render(width);
|
|
195
|
-
this.leadingRows = lines[0] === "" ? 1 : 0;
|
|
196
|
-
return lines.slice(this.leadingRows);
|
|
197
|
-
}
|
|
198
|
-
handleMouse(event: TuiMouseEvent) {
|
|
199
|
-
if (rowData(this.row).expanded)
|
|
200
|
-
return this.row.handleMouse({ ...event, y: event.y + this.leadingRows, height: event.height + this.leadingRows });
|
|
201
|
-
if (event.type !== "click" || event.button !== "left" || !rowData(this.row).result) return undefined;
|
|
202
|
-
this.toggle(this.row);
|
|
203
|
-
return { handled: true };
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
const OSC133_PREFIX = /^(?:\x1b\]133;[ABC](?:\x07|\x1b\\))+/;
|
|
208
|
-
|
|
209
|
-
/** 人类输入:左侧橙色竖条,正文复用宿主用户消息。 */
|
|
210
|
-
class UserBar implements Component {
|
|
211
|
-
constructor(private readonly message: Component) {}
|
|
212
|
-
invalidate(): void {
|
|
213
|
-
this.message.invalidate();
|
|
214
|
-
}
|
|
215
|
-
render(width: number): string[] {
|
|
216
|
-
if (width <= 2) return this.message.render(width);
|
|
217
|
-
const bar = paint(HEAT_COLORS.orange, "▌");
|
|
218
|
-
const lines = this.message.render(width - 1);
|
|
219
|
-
// 宿主上下各留一行背景内边距:去掉,竖条从第一行正文开始、到最后一行正文结束(主会话与子代理视图同一组件)。
|
|
220
|
-
// 去掉的内边距行上若带 OSC 133 语义标记,挪到相邻正文行的行首:行中的 133;A 会被终端当 fresh-line 执行 CR+LF,留下残影。
|
|
221
|
-
const blank = (line: string | undefined) => line !== undefined && !stripVTControlCharacters(line).trim();
|
|
222
|
-
const head = blank(lines[0]) ? OSC133_PREFIX.exec(lines.shift()!)?.[0] ?? "" : "";
|
|
223
|
-
const tail = lines.length > 1 && blank(lines.at(-1)) ? OSC133_PREFIX.exec(lines.pop()!)?.[0] ?? "" : "";
|
|
224
|
-
return lines.map((line, index) => {
|
|
225
|
-
const carried = (index === 0 ? head : "") + (index === lines.length - 1 ? tail : "");
|
|
226
|
-
const mark = OSC133_PREFIX.exec(line)?.[0] ?? "";
|
|
227
|
-
return carried + mark + bar + line.slice(mark.length);
|
|
228
|
-
});
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/** 投影只用到宿主 UI 的主题与全局展开档位;主会话传 ctx.ui,子代理全过程视图传自己的一份。 */
|
|
233
|
-
export type ProjectionUI = Pick<ExtensionUIContext, "theme" | "getToolsExpanded">;
|
|
234
|
-
|
|
235
|
-
export interface ProjectionEnv {
|
|
236
|
-
ui: ProjectionUI;
|
|
237
|
-
clock: TurnClock;
|
|
238
|
-
toggleRow: (row: ToolRow) => void;
|
|
239
|
-
/** 相对全局档位被点击翻转过的键:该轮的人类用户消息,或被点开的机器消息本身;全局档位变化时清空。 */
|
|
240
|
-
isOpen: (key: object) => boolean;
|
|
241
|
-
toggleOpen: (key: object) => void;
|
|
242
|
-
/** 第一条人类输入之前的过程所属的轮次键。 */
|
|
243
|
-
headless: object;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
export interface Projection {
|
|
247
|
-
nodes: Component[];
|
|
248
|
-
/** 有活的摘要或落定过渡在播放,宿主需要按帧重绘。 */
|
|
249
|
-
animating: boolean;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* 开轮的人类消息:没有更早的人类消息,或与上一条人类消息之间已有轮记录(上一段已歇下)。
|
|
254
|
-
* 其余人类消息是本段内的补话(steer、等子代理时的追加),折进当前轮,不另起一轮。只看聊天树,不另存状态。
|
|
255
|
-
*/
|
|
256
|
-
function openersOf(children: readonly Component[]): Set<Component> {
|
|
257
|
-
const openers = new Set<Component>();
|
|
258
|
-
let seenHuman = false;
|
|
259
|
-
let settled = false;
|
|
260
|
-
for (const child of children) {
|
|
261
|
-
if (roundOf(child)) settled = true;
|
|
262
|
-
else if (isHuman(child)) {
|
|
263
|
-
if (!seenHuman || settled) openers.add(child);
|
|
264
|
-
seenHuman = true;
|
|
265
|
-
settled = false;
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
return openers;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/** 宿主对 ctrl+o 的回显连同它前面的 Spacer 不进投影。 */
|
|
272
|
-
function withoutEchoes(children: readonly Component[]): Component[] {
|
|
273
|
-
return children.filter((child, index) => !isToolOutputEcho(child) && !(child instanceof Spacer && isToolOutputEcho(children[index + 1])));
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/** 从宿主组件顺序投影,既不搬走原组件,也不另存工具调用或展开档位。 */
|
|
277
|
-
export function projectProcessGroups(source: readonly Component[], env: ProjectionEnv): Projection {
|
|
278
|
-
const children = withoutEchoes(source);
|
|
279
|
-
const openers = openersOf(children);
|
|
280
|
-
const isProcess = (component: Component) => isModelProcess(component) || (isHuman(component) && !openers.has(component));
|
|
281
|
-
const nodes: Component[] = [];
|
|
282
|
-
let animating = false;
|
|
283
|
-
let turn = env.headless;
|
|
284
|
-
let segment: Component[] = [];
|
|
285
|
-
env.clock.track([...openers].at(-1) ?? env.headless);
|
|
286
|
-
const flush = (final: boolean) => {
|
|
287
|
-
// 进行中的轮在第一个助手组件到来之前也有摘要行(思考中)。
|
|
288
|
-
if (!segment.length && !(final && env.clock.live(turn))) return;
|
|
289
|
-
const view = renderSegment(segment, turn, final, isProcess, env);
|
|
290
|
-
nodes.push(...view.nodes);
|
|
291
|
-
animating ||= view.animating;
|
|
292
|
-
segment = [];
|
|
293
|
-
};
|
|
294
|
-
const inSegment = (index: number) => {
|
|
295
|
-
const child = children[index];
|
|
296
|
-
if (isProcess(child)) return true;
|
|
297
|
-
// 轮记录等 CustomEntry 属于本轮:有段可依附时不切段。
|
|
298
|
-
if (isEntry(child)) return segment.length > 0;
|
|
299
|
-
// 宿主在用户消息(含空闲送达的信封)与提示前先插一个 Spacer:它跟着后面的节点走,
|
|
300
|
-
// 后面的节点属于本段,它就属于本段。
|
|
301
|
-
if (child instanceof Spacer) return segment.length > 0 && leadsInto(children[index + 1]);
|
|
302
|
-
return segment.some(hasSubstance) && noticeKind(child, env.ui.theme) !== undefined;
|
|
303
|
-
};
|
|
304
|
-
const leadsInto = (next: Component | undefined) =>
|
|
305
|
-
next !== undefined && (isProcess(next) || isEntry(next) || (segment.some(hasSubstance) && noticeKind(next, env.ui.theme) !== undefined));
|
|
306
|
-
for (let index = 0; index < children.length; index++) {
|
|
307
|
-
const child = children[index];
|
|
308
|
-
if (openers.has(child)) {
|
|
309
|
-
flush(false);
|
|
310
|
-
turn = child;
|
|
311
|
-
nodes.push(new UserBar(child));
|
|
312
|
-
} else if (inSegment(index)) segment.push(child);
|
|
313
|
-
else if (!roundOf(child)) {
|
|
314
|
-
// 无段可依附的轮记录(这一段没有任何过程)没有摘要行可画,不让它的宿主壳空出一行。
|
|
315
|
-
flush(false);
|
|
316
|
-
nodes.push(child);
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
flush(true);
|
|
320
|
-
return { nodes, animating };
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
type IsProcess = (component: Component) => boolean;
|
|
324
|
-
|
|
325
|
-
/** 段尾回复 = 其后只剩提示与机器消息的最后一条助手消息(其后有补话就还没有段尾回复);回复留在摘要下方,其余折进摘要。 */
|
|
326
|
-
function tailReply(segment: readonly Component[], isProcess: IsProcess) {
|
|
327
|
-
let at = segment.length - 1;
|
|
328
|
-
while (at >= 0 && (!isProcess(segment[at]) || machineEntriesOf(segment[at]))) at--;
|
|
329
|
-
const tail = segment[at];
|
|
330
|
-
const reply = tail instanceof AssistantMessageComponent ? assistantView(tail, false) : undefined;
|
|
331
|
-
return { tail, reply };
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function renderSegment(segment: readonly Component[], turn: object, final: boolean, isProcess: IsProcess, env: ProjectionEnv) {
|
|
335
|
-
const { tail, reply } = tailReply(segment, isProcess);
|
|
336
|
-
const globalOpen = env.ui.getToolsExpanded();
|
|
337
|
-
// 逐轮点击只是相对全局档位的覆盖:全局折叠时点开,全局展开时折起。
|
|
338
|
-
const open = globalOpen !== env.isOpen(turn);
|
|
339
|
-
const facts = scan(segment, reply?.activity, env);
|
|
340
|
-
const live = final && env.clock.live(turn);
|
|
341
|
-
// 进行中的轮一律有摘要行:纯文字轮从开始到歇下都占着这一行,回复不跳。
|
|
342
|
-
const hasSummary = live || segment.some(hasSubstance);
|
|
343
|
-
const round = live ? undefined : facts.round;
|
|
344
|
-
const sinceEnd = round && env.clock.now() - round.at;
|
|
345
|
-
const nodes: Component[] = [];
|
|
346
|
-
if (hasSummary) {
|
|
347
|
-
nodes.push(new Spacer(1), new TurnSummary({
|
|
348
|
-
// 运行中失败行常驻在子代理活动列表,摘要行只在事后留痕。
|
|
349
|
-
...facts, failures: live ? 0 : facts.failures, live, round, sinceEnd,
|
|
350
|
-
toggle: () => env.toggleOpen(turn),
|
|
351
|
-
}, env.ui.theme));
|
|
352
|
-
}
|
|
353
|
-
if (open) nodes.push(...processList(segment, env));
|
|
354
|
-
else nodes.push(...foldedReplies(segment, hasSummary && reply?.body ? tail : undefined, reply?.body, hasSummary, env));
|
|
355
|
-
return { nodes, animating: hasSummary && (live || (sinceEnd !== undefined && settling(sinceEnd))) };
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
/**
|
|
359
|
-
* 折叠态按时间顺序:先是第一条补话之前的中间回复,之后每条补话(人类原话,竖条全文)后面跟它那一段的中间回复;
|
|
360
|
-
* 每组各自只列最近 REPLY_LINES 条首句,更早的在组内折成“+N 条”。最后接段尾回复全文。
|
|
361
|
-
*/
|
|
362
|
-
function foldedReplies(
|
|
363
|
-
segment: readonly Component[],
|
|
364
|
-
tail: Component | undefined,
|
|
365
|
-
body: Component | undefined,
|
|
366
|
-
hasSummary: boolean,
|
|
367
|
-
env: ProjectionEnv,
|
|
368
|
-
): Component[] {
|
|
369
|
-
const out: Component[] = [];
|
|
370
|
-
const { theme } = env.ui;
|
|
371
|
-
const groups: { message?: UserMessageComponent; replies: string[] }[] = [{ replies: [] }];
|
|
372
|
-
for (const item of segment) {
|
|
373
|
-
if (item instanceof UserMessageComponent && isHuman(item)) groups.push({ message: item, replies: [] });
|
|
374
|
-
else if (item instanceof AssistantMessageComponent && item !== tail) {
|
|
375
|
-
const text = replyText(item);
|
|
376
|
-
if (text) groups[groups.length - 1].replies.push(firstSentence(text));
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
const listing = hasSummary;
|
|
380
|
-
for (const { message, replies } of groups) {
|
|
381
|
-
// 前后留白与开轮的用户消息一致:上方隔一行,竖条消息自带上下内边距。
|
|
382
|
-
if (message) out.push(new Spacer(1), new UserBar(message));
|
|
383
|
-
if (!listing || !replies.length) continue;
|
|
384
|
-
const shown = replies.slice(-REPLY_LINES);
|
|
385
|
-
// 与宿主正文同一左边距(1 列)。
|
|
386
|
-
out.push(new Spacer(1));
|
|
387
|
-
if (replies.length > shown.length) out.push(new Line(` ${theme.fg("dim", `+${replies.length - shown.length} 条`)}`));
|
|
388
|
-
for (const text of shown) out.push(new Line(` ${theme.fg("muted", text)}`));
|
|
389
|
-
}
|
|
390
|
-
// 宿主助手正文自带前导空行,不再另垫。
|
|
391
|
-
if (body) out.push(body);
|
|
392
|
-
return out;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
function processList(segment: readonly Component[], env: ProjectionEnv): Component[] {
|
|
396
|
-
const list: Component[] = [];
|
|
397
|
-
for (const [index, item] of segment.entries()) {
|
|
398
|
-
// 机器消息前的宿主 Spacer 已由列表自己的间距取代;轮记录零行,已在摘要行体现。
|
|
399
|
-
if (item instanceof Spacer && machineEntriesOf(segment[index + 1] ?? item)) continue;
|
|
400
|
-
if (roundOf(item)) continue;
|
|
401
|
-
const machine = machineEntriesOf(item);
|
|
402
|
-
if (machine) {
|
|
403
|
-
// 与上一个工具行或正文空一行,免得 ↳ 行像是贴在它底下;连续的 ↳ 行紧挨。
|
|
404
|
-
if (!(list.at(-1) instanceof MachineItem || list.at(-1) instanceof Spacer)) list.push(new Spacer(1));
|
|
405
|
-
list.push(new MachineItem(item, machine, env));
|
|
406
|
-
} else if (item instanceof ToolExecutionComponent) {
|
|
407
|
-
if (!(list.at(-1) instanceof ToolItem)) list.push(new Spacer(1));
|
|
408
|
-
list.push(new ToolItem(item, env.ui, env.toggleRow));
|
|
409
|
-
} else if (item instanceof AssistantMessageComponent) {
|
|
410
|
-
const body = assistantView(item, true).body;
|
|
411
|
-
if (body) list.push(body);
|
|
412
|
-
} else if (item instanceof UserMessageComponent) list.push(new UserBar(item));
|
|
413
|
-
else list.push(item);
|
|
414
|
-
}
|
|
415
|
-
return list;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
export function toggleToolDetails(row: ToolRow, setExpanded: ToolRow["setExpanded"]): void {
|
|
419
|
-
setExpanded.call(row, !rowData(row).expanded);
|
|
420
|
-
}
|