@yinan_chen/pi-claude-code-ui 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +36 -2
- package/package.json +34 -5
- package/src/claude-tools.ts +274 -0
- package/src/claude-view.ts +775 -0
- package/src/index.ts +566 -0
- package/src/pi-runtime.ts +232 -0
- package/src/preview.ts +147 -0
- package/src/running-effect.ts +29 -0
- package/src/skill-block-recollapse.ts +362 -0
- package/src/sticky-first-message.ts +1114 -0
- package/src/subagent-panel.ts +144 -0
- package/src/vendor/ATTRIBUTION.md +26 -0
- package/src/vendor/ansi-color.ts +44 -0
- package/src/vendor/ansi-text.ts +47 -0
- package/src/vendor/diff/ATTRIBUTION.md +28 -0
- package/src/vendor/diff/ansi-utils.ts +113 -0
- package/src/vendor/diff/diff-component.ts +125 -0
- package/src/vendor/diff/diff-edit-render.ts +237 -0
- package/src/vendor/diff/diff-header.ts +137 -0
- package/src/vendor/diff/diff-highlight.ts +115 -0
- package/src/vendor/diff/diff-inline.ts +324 -0
- package/src/vendor/diff/diff-layout.ts +762 -0
- package/src/vendor/diff/diff-limits.ts +229 -0
- package/src/vendor/diff/diff-palette.ts +410 -0
- package/src/vendor/diff/diff-parse.ts +535 -0
- package/src/vendor/diff/diff-presentation.ts +70 -0
- package/src/vendor/diff/diff-renderer.ts +23 -0
- package/src/vendor/diff/diff-text.ts +45 -0
- package/src/vendor/diff/diff-write-render.ts +572 -0
- package/src/vendor/diff/index.ts +126 -0
- package/src/vendor/diff/line-width-safety.ts +92 -0
- package/src/vendor/diff/shiki-highlight.ts +92 -0
- package/src/vendor/diff/write-execution.ts +113 -0
- package/src/vendor/diff-config.ts +30 -0
- package/src/vendor/show-more-hint.ts +38 -0
- package/src/vendor/tool-result-sanitize.ts +24 -0
- package/src/working-message.ts +140 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { normalizeDiffRenderWidth } from "./diff-presentation.ts";
|
|
2
|
+
import { pluralize } from "./diff-text.ts";
|
|
3
|
+
import { showMoreHintText } from "../show-more-hint.ts";
|
|
4
|
+
|
|
5
|
+
export interface WidthMeasurementOps {
|
|
6
|
+
measure(text: string): number;
|
|
7
|
+
truncate(text: string, maxWidth: number): string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface CollapsedDiffHintOptions {
|
|
11
|
+
remainingLines: number;
|
|
12
|
+
hiddenHunks: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function guardSafeWidth(width: number): number | undefined {
|
|
16
|
+
const safe = normalizeDiffRenderWidth(width);
|
|
17
|
+
return safe === 0 ? undefined : safe;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function renderWithSafeWidth<T>(width: number, fallback: T, render: (safeWidth: number) => T): T {
|
|
21
|
+
const safeWidth = guardSafeWidth(width);
|
|
22
|
+
if (safeWidth === undefined) {
|
|
23
|
+
return fallback;
|
|
24
|
+
}
|
|
25
|
+
return render(safeWidth);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function clampRenderedLineToWidth(
|
|
29
|
+
text: string,
|
|
30
|
+
width: number,
|
|
31
|
+
ops: WidthMeasurementOps,
|
|
32
|
+
): string {
|
|
33
|
+
return renderWithSafeWidth(width, "", (safeWidth) => {
|
|
34
|
+
if (ops.measure(text) <= safeWidth) {
|
|
35
|
+
return text;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (let targetWidth = safeWidth; targetWidth >= 0; targetWidth--) {
|
|
39
|
+
const candidate = ops.truncate(text, targetWidth);
|
|
40
|
+
if (ops.measure(candidate) <= safeWidth) {
|
|
41
|
+
return candidate;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return "";
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function clampRenderedLinesToWidth(
|
|
50
|
+
lines: string[],
|
|
51
|
+
width: number,
|
|
52
|
+
ops: WidthMeasurementOps,
|
|
53
|
+
): string[] {
|
|
54
|
+
return lines.map((line) => clampRenderedLineToWidth(line, width, ops));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function buildCollapsedDiffHintText(
|
|
58
|
+
options: CollapsedDiffHintOptions,
|
|
59
|
+
width: number,
|
|
60
|
+
ops: WidthMeasurementOps,
|
|
61
|
+
): string {
|
|
62
|
+
return renderWithSafeWidth(width, "", (safeWidth) => {
|
|
63
|
+
const remainingText = `${options.remainingLines} more ${pluralize(options.remainingLines, "diff line")}`;
|
|
64
|
+
const hiddenHunksText =
|
|
65
|
+
options.hiddenHunks > 0
|
|
66
|
+
? `${options.hiddenHunks} more ${pluralize(options.hiddenHunks, "hunk")}`
|
|
67
|
+
: undefined;
|
|
68
|
+
const shortRemainingText = `${options.remainingLines} more ${pluralize(options.remainingLines, "line")}`;
|
|
69
|
+
const shortHiddenHunksText =
|
|
70
|
+
options.hiddenHunks > 0
|
|
71
|
+
? `${options.hiddenHunks} ${pluralize(options.hiddenHunks, "hunk")}`
|
|
72
|
+
: undefined;
|
|
73
|
+
|
|
74
|
+
const candidates = [
|
|
75
|
+
`… (${[remainingText, hiddenHunksText, showMoreHintText()].filter(Boolean).join(" • ")})`,
|
|
76
|
+
`… (${[remainingText, hiddenHunksText].filter(Boolean).join(" • ")})`,
|
|
77
|
+
`… (${[shortRemainingText, shortHiddenHunksText].filter(Boolean).join(" • ")})`,
|
|
78
|
+
options.hiddenHunks > 0
|
|
79
|
+
? `… (+${options.remainingLines} • +${options.hiddenHunks}h)`
|
|
80
|
+
: `… (+${options.remainingLines})`,
|
|
81
|
+
"…",
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
for (const candidate of candidates) {
|
|
85
|
+
if (ops.measure(candidate) <= safeWidth) {
|
|
86
|
+
return candidate;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return clampRenderedLineToWidth(candidates[candidates.length - 1] ?? "", safeWidth, ops);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export const MAX_HL_CHARS = 32_000;
|
|
2
|
+
const MAX_CACHE_ENTRIES = 48;
|
|
3
|
+
|
|
4
|
+
type CodeToAnsi = (code: string, lang: any, theme: any) => Promise<string>;
|
|
5
|
+
type Loader = () => Promise<CodeToAnsi>;
|
|
6
|
+
type Subscriber = { fallback: readonly string[]; invalidate: () => void };
|
|
7
|
+
type Pending = { work: Promise<void>; subscribers: Set<Subscriber> };
|
|
8
|
+
|
|
9
|
+
let defaultLoad: Promise<CodeToAnsi> | undefined;
|
|
10
|
+
const loadDefault: Loader = () => {
|
|
11
|
+
if (!defaultLoad) {
|
|
12
|
+
// 变量形式的动态导入:tsc 不解析、运行时找不到时由上层 catch 降级为纯文本行。
|
|
13
|
+
const shikiCliModule = "@shikijs/cli" as string;
|
|
14
|
+
defaultLoad = import(shikiCliModule)
|
|
15
|
+
.then((module: { codeToANSI?: CodeToAnsi }) => module.codeToANSI as CodeToAnsi)
|
|
16
|
+
.catch((error) => {
|
|
17
|
+
defaultLoad = undefined;
|
|
18
|
+
throw error;
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
return defaultLoad;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function differs(lines: readonly string[], fallback: readonly string[]): boolean {
|
|
25
|
+
return lines.length !== fallback.length || lines.some((line, index) => line !== fallback[index]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class ShikiHighlightCache {
|
|
29
|
+
private readonly cache = new Map<string, string[]>();
|
|
30
|
+
private readonly pending = new Map<string, Pending>();
|
|
31
|
+
private readonly loader: Loader;
|
|
32
|
+
|
|
33
|
+
constructor(loader: Loader = loadDefault) {
|
|
34
|
+
this.loader = loader;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
get(
|
|
38
|
+
code: string,
|
|
39
|
+
lang: string | undefined,
|
|
40
|
+
theme: string,
|
|
41
|
+
fallback: readonly string[],
|
|
42
|
+
invalidate?: () => void,
|
|
43
|
+
): string[] | undefined {
|
|
44
|
+
if (!lang || code.length > MAX_HL_CHARS) return undefined;
|
|
45
|
+
const key = `${theme}\0${lang}\0${code}`;
|
|
46
|
+
const cached = this.cache.get(key);
|
|
47
|
+
if (cached) {
|
|
48
|
+
this.cache.delete(key);
|
|
49
|
+
this.cache.set(key, cached);
|
|
50
|
+
return cached;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const existing = this.pending.get(key);
|
|
54
|
+
if (existing) {
|
|
55
|
+
if (invalidate && ![...existing.subscribers].some((item) => item.invalidate === invalidate)) {
|
|
56
|
+
existing.subscribers.add({ fallback, invalidate });
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const pending: Pending = { work: undefined as never, subscribers: new Set() };
|
|
62
|
+
if (invalidate) pending.subscribers.add({ fallback, invalidate });
|
|
63
|
+
pending.work = this.loader()
|
|
64
|
+
.then((highlight) => highlight(code, lang, theme))
|
|
65
|
+
.then((ansi) => {
|
|
66
|
+
const lines = ansi.replace(/\r/g, "").replace(/\n$/, "").split("\n");
|
|
67
|
+
this.cache.set(key, lines);
|
|
68
|
+
while (this.cache.size > MAX_CACHE_ENTRIES) {
|
|
69
|
+
const oldest = this.cache.keys().next().value;
|
|
70
|
+
if (oldest !== undefined) this.cache.delete(oldest);
|
|
71
|
+
}
|
|
72
|
+
for (const subscriber of pending.subscribers) {
|
|
73
|
+
if (!differs(lines, subscriber.fallback)) continue;
|
|
74
|
+
try {
|
|
75
|
+
subscriber.invalidate();
|
|
76
|
+
} catch {
|
|
77
|
+
// One component must not prevent the other subscribers from repainting.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
.catch(() => {})
|
|
82
|
+
.finally(() => this.pending.delete(key));
|
|
83
|
+
this.pending.set(key, pending);
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
clear(): void {
|
|
88
|
+
this.cache.clear();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const shikiHighlightCache = new ShikiHighlightCache();
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { lstat, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
export const MAX_COMPARABLE_WRITE_BYTES = 512_000;
|
|
6
|
+
export const MAX_WRITE_METADATA_ENTRIES = 100;
|
|
7
|
+
|
|
8
|
+
export type WriteExecutionMeta = {
|
|
9
|
+
fileExistedBeforeWrite: boolean;
|
|
10
|
+
previousContent?: string;
|
|
11
|
+
diffUnavailableReason?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export class WriteExecutionMetadataStore {
|
|
15
|
+
readonly entries = new Map<string, WriteExecutionMeta>();
|
|
16
|
+
|
|
17
|
+
set(toolCallId: string, metadata: WriteExecutionMeta): void {
|
|
18
|
+
this.entries.delete(toolCallId);
|
|
19
|
+
this.entries.set(toolCallId, metadata);
|
|
20
|
+
while (this.entries.size > MAX_WRITE_METADATA_ENTRIES) {
|
|
21
|
+
const oldest = this.entries.keys().next().value;
|
|
22
|
+
if (oldest === undefined) break;
|
|
23
|
+
this.entries.delete(oldest);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get(toolCallId: unknown): WriteExecutionMeta | undefined {
|
|
28
|
+
return typeof toolCallId === "string" ? this.entries.get(toolCallId) : undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
delete(toolCallId: string): void {
|
|
32
|
+
this.entries.delete(toolCallId);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
clear(): void {
|
|
36
|
+
this.entries.clear();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function capturePreviousContent(absolutePath: string): Promise<WriteExecutionMeta> {
|
|
41
|
+
let info;
|
|
42
|
+
try {
|
|
43
|
+
info = await lstat(absolutePath);
|
|
44
|
+
} catch (error: any) {
|
|
45
|
+
if (error?.code === "ENOENT") return { fileExistedBeforeWrite: false };
|
|
46
|
+
return {
|
|
47
|
+
fileExistedBeforeWrite: true,
|
|
48
|
+
diffUnavailableReason: "unable to inspect the previous file",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!info.isFile()) {
|
|
53
|
+
return {
|
|
54
|
+
fileExistedBeforeWrite: true,
|
|
55
|
+
diffUnavailableReason: "previous path is not a regular file",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (info.size > MAX_COMPARABLE_WRITE_BYTES) {
|
|
59
|
+
return {
|
|
60
|
+
fileExistedBeforeWrite: true,
|
|
61
|
+
diffUnavailableReason: `previous file exceeds ${MAX_COMPARABLE_WRITE_BYTES} bytes`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const bytes = await readFile(absolutePath);
|
|
67
|
+
const previousContent = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
68
|
+
return { fileExistedBeforeWrite: true, previousContent };
|
|
69
|
+
} catch {
|
|
70
|
+
return {
|
|
71
|
+
fileExistedBeforeWrite: true,
|
|
72
|
+
diffUnavailableReason: "previous file is not comparable UTF-8 text",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function executeWriteWithMetadata(
|
|
78
|
+
store: WriteExecutionMetadataStore,
|
|
79
|
+
toolCallId: string,
|
|
80
|
+
params: { path: string; content: string },
|
|
81
|
+
signal: AbortSignal | undefined,
|
|
82
|
+
cwd: string,
|
|
83
|
+
): Promise<{ content: Array<{ type: "text"; text: string }>; details: undefined }> {
|
|
84
|
+
const absolutePath = isAbsolute(params.path) ? params.path : resolve(cwd, params.path);
|
|
85
|
+
store.delete(toolCallId);
|
|
86
|
+
try {
|
|
87
|
+
return await withFileMutationQueue(absolutePath, async () => {
|
|
88
|
+
const throwIfAborted = () => {
|
|
89
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
90
|
+
};
|
|
91
|
+
throwIfAborted();
|
|
92
|
+
const metadata = await capturePreviousContent(absolutePath);
|
|
93
|
+
throwIfAborted();
|
|
94
|
+
await mkdir(dirname(absolutePath), { recursive: true });
|
|
95
|
+
throwIfAborted();
|
|
96
|
+
await writeFile(absolutePath, params.content, "utf8");
|
|
97
|
+
throwIfAborted();
|
|
98
|
+
store.set(toolCallId, metadata);
|
|
99
|
+
return {
|
|
100
|
+
content: [
|
|
101
|
+
{
|
|
102
|
+
type: "text",
|
|
103
|
+
text: `Successfully wrote ${params.content.length} bytes to ${params.path}`,
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
details: undefined,
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
} catch (error) {
|
|
110
|
+
store.delete(toolCallId);
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vendored diff 模块的显示配置段。
|
|
3
|
+
* 类型与默认值取自 pi-cc-extensions extensions/config/config.ts 的 ToolDisplay 段(MIT),
|
|
4
|
+
* 值由 lean-tidy.json 的 "diff" 对象桥接(见 index.ts 的 loadConfig)。
|
|
5
|
+
*/
|
|
6
|
+
export type DiffViewMode = "auto" | "split" | "unified";
|
|
7
|
+
export type DiffIndicatorMode = "bars" | "classic" | "none";
|
|
8
|
+
|
|
9
|
+
export type ToolDisplayConfig = {
|
|
10
|
+
diffViewMode: DiffViewMode;
|
|
11
|
+
diffIndicatorMode: DiffIndicatorMode;
|
|
12
|
+
diffSplitMinWidth: number;
|
|
13
|
+
editDiffCollapsedLines: number;
|
|
14
|
+
writeDiffCollapsedLines: number;
|
|
15
|
+
diffWordWrap: boolean;
|
|
16
|
+
expandedPreviewMaxLines: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_TOOL_DISPLAY_CONFIG: ToolDisplayConfig = {
|
|
20
|
+
diffViewMode: "auto",
|
|
21
|
+
diffIndicatorMode: "bars",
|
|
22
|
+
diffSplitMinWidth: 120,
|
|
23
|
+
editDiffCollapsedLines: 24,
|
|
24
|
+
writeDiffCollapsedLines: 0,
|
|
25
|
+
diffWordWrap: true,
|
|
26
|
+
expandedPreviewMaxLines: 40,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** 原 patch-keys.ts 中 rich diff 使用的组件标记 symbol。 */
|
|
30
|
+
export const RICH_DIFF_COMPONENT = Symbol.for("lean-tidy.rich-diff-component");
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* show-more 提示文本:fullscreen 下鼠标 hover/click 可用,提示 "click to show more";
|
|
3
|
+
* 其他模式(regular 保留终端原生 scrollback、不启用鼠标上报)提示默认展开快捷键。
|
|
4
|
+
*
|
|
5
|
+
* tui 模式由 mouse-interaction 在 renderer 回调中维护;未安装时按 fullscreen 处理,
|
|
6
|
+
* 保证纯渲染测试与既有行为不变。
|
|
7
|
+
*/
|
|
8
|
+
import { getKeybindings } from "@earendil-works/pi-tui";
|
|
9
|
+
|
|
10
|
+
let toolTuiFullscreen: boolean | undefined;
|
|
11
|
+
|
|
12
|
+
export function setToolTuiFullscreen(value: boolean): void {
|
|
13
|
+
toolTuiFullscreen = value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isToolTuiFullscreen(): boolean {
|
|
17
|
+
return toolTuiFullscreen !== false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function formatKeyPart(part: string): string {
|
|
21
|
+
// 与 pi 内置 keybinding-hints 一致:macOS 上 alt 显示为 option
|
|
22
|
+
return process.platform === "darwin" && part.toLowerCase() === "alt" ? "option" : part;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 默认展开快捷键(app.tools.expand)显示文本;未配置时回退 ctrl+o。 */
|
|
26
|
+
export function expandShortcutText(): string {
|
|
27
|
+
const keys = getKeybindings().getKeys("app.tools.expand");
|
|
28
|
+
if (keys.length === 0) return "ctrl+o";
|
|
29
|
+
return keys
|
|
30
|
+
.join("/")
|
|
31
|
+
.split("/")
|
|
32
|
+
.map((part) => part.split("+").map(formatKeyPart).join("+"))
|
|
33
|
+
.join("/");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function showMoreHintText(): string {
|
|
37
|
+
return isToolTuiFullscreen() ? "click to show more" : `${expandShortcutText()} to show more`;
|
|
38
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const UNSAFE_TERMINAL_ESCAPE = new RegExp(
|
|
2
|
+
"(?:\\u001B\\]|\\u009D)[\\s\\S]*?(?:\\u0007|\\u001B\\x5C|\\u009C)" +
|
|
3
|
+
"|(?:\\u001B[PX^_]|\\u0090)[\\s\\S]*?(?:\\u001B\\x5C|\\u009C)" +
|
|
4
|
+
"|(?:\\u001B\\[|\\u009B)[0-?]*[ -/]*[@-~]" +
|
|
5
|
+
"|\\u001B[@-_]",
|
|
6
|
+
"g",
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Prevent captured terminal control responses from being replayed by tool renderers.
|
|
11
|
+
* Optional `maxChars` trims the input first so preview/one-line paths never scan multi-MB outputs.
|
|
12
|
+
*/
|
|
13
|
+
export function sanitizeToolResultText(value: string, maxChars?: number): string {
|
|
14
|
+
const source =
|
|
15
|
+
typeof maxChars === "number" && maxChars >= 0 && value.length > maxChars
|
|
16
|
+
? value.slice(0, maxChars)
|
|
17
|
+
: value;
|
|
18
|
+
return source
|
|
19
|
+
.replace(UNSAFE_TERMINAL_ESCAPE, "")
|
|
20
|
+
.replace(/\x1B/g, "")
|
|
21
|
+
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g, "")
|
|
22
|
+
.replace(/\r\n/g, "\n")
|
|
23
|
+
.replace(/\r/g, "\n");
|
|
24
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
// Vendored from minuque/pi-cc-extensions (MIT); see vendor/diff/ATTRIBUTION.md for diff's separate provenance.
|
|
3
|
+
// This file only owns Pi's Working... row, not the user's custom footer.
|
|
4
|
+
function formatDuration(ms: number): string {
|
|
5
|
+
const seconds = Math.floor(ms / 1_000);
|
|
6
|
+
if (seconds < 1) return "";
|
|
7
|
+
const hours = Math.floor(seconds / 3_600);
|
|
8
|
+
const minutes = Math.floor((seconds % 3_600) / 60);
|
|
9
|
+
const rest = seconds % 60;
|
|
10
|
+
return hours ? `${hours}h ${minutes}m ${rest}s` : minutes ? `${minutes}m ${rest}s` : `${rest}s`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const REFRESH_INTERVAL_MS = 1_000;
|
|
14
|
+
/** Elapsed time is only shown once the turn has run this long. */
|
|
15
|
+
const SHOW_TIMER_AFTER_MS = 3_000;
|
|
16
|
+
|
|
17
|
+
type WorkingUi = {
|
|
18
|
+
setWorkingMessage(message?: string): void;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Extend Pi's footer working row while preserving its spinner and "Working...":
|
|
23
|
+
* `⠋ Working... (12s)`
|
|
24
|
+
*
|
|
25
|
+
* Timer spans the whole agent loop (一南 2026-09-26): a user message triggers
|
|
26
|
+
* several assistant runs (thinking → tool → … → final prose); elapsed time
|
|
27
|
+
* counts from the first run and only resets on agent_settled — not on every
|
|
28
|
+
* turn_start, which used to restart the clock from zero mid-turn.
|
|
29
|
+
*
|
|
30
|
+
* Token counting was removed on 一南's request (2026-11-27): only time is shown.
|
|
31
|
+
*/
|
|
32
|
+
export default function (pi: ExtensionAPI): void {
|
|
33
|
+
let turnActive = false;
|
|
34
|
+
let agentStartTime = 0;
|
|
35
|
+
let turnStartTime = 0;
|
|
36
|
+
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
|
37
|
+
let lastMessage: string | null = null;
|
|
38
|
+
let activeCtx: { ui: WorkingUi | undefined; hasUI: boolean } | null = null;
|
|
39
|
+
|
|
40
|
+
function buildWorkingMessage(): string {
|
|
41
|
+
const elapsed = Date.now() - (agentStartTime || turnStartTime);
|
|
42
|
+
if (elapsed < SHOW_TIMER_AFTER_MS) return "";
|
|
43
|
+
// formatDuration 低于 1 秒返回 "",此处回退 "0s" 保持计时器连续跳动。
|
|
44
|
+
return `Working... (${formatDuration(elapsed) || "0s"})`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function restoreDefaultWorkingMessage(): void {
|
|
48
|
+
lastMessage = null;
|
|
49
|
+
if (!activeCtx?.hasUI) return;
|
|
50
|
+
try {
|
|
51
|
+
activeCtx.ui?.setWorkingMessage();
|
|
52
|
+
} catch {
|
|
53
|
+
// Noop when the TUI is unavailable.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function syncWorkingMessage(force = false): void {
|
|
58
|
+
if (!activeCtx?.hasUI) return;
|
|
59
|
+
const next = buildWorkingMessage();
|
|
60
|
+
if (!next) {
|
|
61
|
+
if (force) restoreDefaultWorkingMessage();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!force && next === lastMessage) return;
|
|
65
|
+
lastMessage = next;
|
|
66
|
+
try {
|
|
67
|
+
activeCtx.ui?.setWorkingMessage(next);
|
|
68
|
+
} catch {
|
|
69
|
+
// Noop when the TUI is unavailable.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function scheduleRefreshTick(): void {
|
|
74
|
+
if (!turnActive || refreshTimer) return;
|
|
75
|
+
refreshTimer = setTimeout(() => {
|
|
76
|
+
refreshTimer = null;
|
|
77
|
+
try {
|
|
78
|
+
syncWorkingMessage();
|
|
79
|
+
} finally {
|
|
80
|
+
scheduleRefreshTick();
|
|
81
|
+
}
|
|
82
|
+
}, REFRESH_INTERVAL_MS);
|
|
83
|
+
refreshTimer.unref?.();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function stopRefreshLoop(): void {
|
|
87
|
+
if (!refreshTimer) return;
|
|
88
|
+
clearTimeout(refreshTimer);
|
|
89
|
+
refreshTimer = null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function clearDisplay(): void {
|
|
93
|
+
stopRefreshLoop();
|
|
94
|
+
agentStartTime = 0;
|
|
95
|
+
turnStartTime = 0;
|
|
96
|
+
restoreDefaultWorkingMessage();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
pi.on("before_agent_start", async () => {
|
|
100
|
+
if (!agentStartTime) agentStartTime = Date.now();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
pi.on("turn_start", async (_event, ctx) => {
|
|
104
|
+
// Deliberately no clock reset here: one user message spans many turns;
|
|
105
|
+
// only agent_settled ends the streak.
|
|
106
|
+
turnActive = true;
|
|
107
|
+
activeCtx = ctx;
|
|
108
|
+
turnStartTime = Date.now();
|
|
109
|
+
if (!agentStartTime) agentStartTime = turnStartTime;
|
|
110
|
+
syncWorkingMessage(true);
|
|
111
|
+
scheduleRefreshTick();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
115
|
+
turnActive = false;
|
|
116
|
+
activeCtx = ctx;
|
|
117
|
+
stopRefreshLoop();
|
|
118
|
+
// No clock reset: the next turn of the same user message keeps counting.
|
|
119
|
+
// No completion message: return immediately to Pi's default idle state.
|
|
120
|
+
restoreDefaultWorkingMessage();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
pi.on("agent_settled", async () => {
|
|
124
|
+
turnActive = false;
|
|
125
|
+
clearDisplay();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
pi.on("agent_end", async () => {
|
|
129
|
+
// A retry or queued continuation can follow agent_end. Only the final
|
|
130
|
+
// agent_settled boundary resets the cross-turn clock.
|
|
131
|
+
turnActive = false;
|
|
132
|
+
stopRefreshLoop();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
pi.on("session_shutdown", async () => {
|
|
136
|
+
turnActive = false;
|
|
137
|
+
clearDisplay();
|
|
138
|
+
activeCtx = null;
|
|
139
|
+
});
|
|
140
|
+
}
|