pi-cc-extensions 1.0.15 → 1.0.16
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.en.md +4 -3
- package/README.md +4 -3
- package/extensions/config/panel.ts +4 -4
- package/extensions/feature/compact-thinking.ts +6 -0
- package/extensions/feature/shell/footer.ts +22 -4
- package/extensions/feature/shell/session-tokens.ts +3 -3
- package/extensions/renderer/compact-mode.ts +22 -2
- package/extensions/renderer/index.ts +5 -0
- package/extensions/renderer/tool/ccstyle-tool.ts +13 -4
- package/extensions/renderer/tool/codemode.ts +2 -1
- package/extensions/renderer/tool/diff/diff-header.ts +4 -4
- package/extensions/renderer/tool/diff/diff-layout.ts +6 -6
- package/extensions/renderer/tool/diff/diff-palette.ts +4 -4
- package/extensions/renderer/tool/diff/diff-presentation.ts +2 -2
- package/extensions/renderer/tool/tool-io.ts +3 -2
- package/extensions/renderer/transcript-mode.ts +457 -0
- package/extensions/renderer/transcript-scope.ts +44 -0
- package/package.json +1 -1
- package/themes/cc-dark-hc.json +2 -4
- package/themes/cc-dark.json +3 -5
- package/themes/cc-light-hc.json +2 -4
- package/themes/cc-light.json +3 -5
package/README.en.md
CHANGED
|
@@ -32,8 +32,9 @@ Run `/reload` after installation.
|
|
|
32
32
|
| Fullscreen mode | Tool card/group expand and collapse on click, previews, hover highlight, and a back-to-bottom button | `TUIMODE=fullscreen` or `--tui-mode fullscreen` |
|
|
33
33
|
| Settings panel | `Style / Features / UI / Diff / Thinking / Footer` tabs | `/ccstyle` |
|
|
34
34
|
| Context inspection | Usage breakdown and previews for the system prompt, memory, skills, tools definition, and messages | `/context` |
|
|
35
|
+
| Transcript browsing | Full-screen transcript browsing with every tool output expanded | `Ctrl+O` |
|
|
35
36
|
| Session/Subagent references | Search and inject effective context from previous Sessions or existing SubAgents | `@` |
|
|
36
|
-
| Status bar | Shows model, context, cache, cost, the conversation token total, and git; also works with `@narumitw/pi-usage` for live quota. Git/cache Nerd Font icons can be turned off | `/ccstyle` |
|
|
37
|
+
| Status bar | Shows model, context, cache, cost, the conversation token total, and git; also works with `@narumitw/pi-usage` for live quota. Git/cache/token Nerd Font icons can be turned off | `/ccstyle` |
|
|
37
38
|
| Theme | Bundled themes: CC Dark, CC Light, CC Dark HC, CC Light HC | `/settings` → Theme |
|
|
38
39
|
|
|
39
40
|
Renderer snapshots for `on` / `compact` modes: [default](./docs/tool-render-examples-default.md) · [compact](./docs/tool-render-examples-compact.md)
|
|
@@ -86,10 +87,10 @@ Renderer snapshots for `on` / `compact` modes: [default](./docs/tool-render-exam
|
|
|
86
87
|
"footerTokenRate": true, // output rate chip; false hides it
|
|
87
88
|
"footerTokenRateMode": "average", // average: whole-prompt avg; live: trailing 1s rate
|
|
88
89
|
"footerTokenRateTtft": true, // append TTFT to the rate chip; false shows tok/s only
|
|
89
|
-
"footerSessionTokens": false, // conversation token total chip (
|
|
90
|
+
"footerSessionTokens": false, // conversation token total chip (Nerd Font glyph, Σ in plain text); sums usage.totalTokens from session entries. Off by default
|
|
90
91
|
"footerCurrency": "USD", // display currency for the footer cost
|
|
91
92
|
"footerCurrencyRate": null, // display units per 1 cost unit; null changes the symbol only
|
|
92
|
-
"footerNerdIcons": true, // Nerd Font glyphs for git/cache; false = plain text
|
|
93
|
+
"footerNerdIcons": true, // Nerd Font glyphs for git/cache/tokens; false = plain text
|
|
93
94
|
"footerGitStatsMode": "working", // working (default): uncommitted; branch: cumulative net changes
|
|
94
95
|
"footerHiddenKeys": [], // hidden chip keys
|
|
95
96
|
"footerLine1Keys": ["model", "context", "cache", "rate", "cost", "tokens", "pi-usage"], // chip order per line; builtins and plugins share one layout
|
package/README.md
CHANGED
|
@@ -32,8 +32,9 @@ pi install git:github.com/minuque/pi-cc-extensions
|
|
|
32
32
|
| Fullscreen mode | 工具卡/group 单击展开与收起、预览、hover 高亮、回到底部按钮 | `TUIMODE=fullscreen` 或 `--tui-mode fullscreen` |
|
|
33
33
|
| 配置面板 | `Style / Features / UI / Diff / Thinking / Footer` 六页签 | `/ccstyle` |
|
|
34
34
|
| 上下文检查 | 查看上下文占用,并预览 System prompt、Memory、Skills、Tools definition 和消息内容 | `/context` |
|
|
35
|
+
| 会话记录浏览 | 全屏浏览完整会话记录,工具输出全展开 | `Ctrl+O` |
|
|
35
36
|
| Session/Subagent 引用 | 搜索并注入历史 Session 或现有 SubAgent 的有效上下文 | `@` |
|
|
36
|
-
| 状态栏 | 显示:模型、上下文、缓存、费用、本次对话累计 token、git并适配 @narumitw/pi-usage 实时显示额度;git
|
|
37
|
+
| 状态栏 | 显示:模型、上下文、缓存、费用、本次对话累计 token、git并适配 @narumitw/pi-usage 实时显示额度;git/缓存/token 图标可关 Nerd Font | `/ccstyle` |
|
|
37
38
|
| 主题 | 随包提供 4 个主题:CC Dark、CC Light、CC Dark HC、CC Light HC | `/settings` → Theme |
|
|
38
39
|
|
|
39
40
|
`on` / `compact` 两种模式的渲染样例:[默认模式](./docs/tool-render-examples-default.md) · [紧凑模式](./docs/tool-render-examples-compact.md)
|
|
@@ -86,10 +87,10 @@ pi install git:github.com/minuque/pi-cc-extensions
|
|
|
86
87
|
"footerTokenRate": true, // 输出速率芯片;false 隐藏
|
|
87
88
|
"footerTokenRateMode": "average", // average:整段指令平均(avg);live:最近 1 秒瞬时(live)
|
|
88
89
|
"footerTokenRateTtft": true, // 速率芯片是否带 TTFT;false 只显示 tok/s
|
|
89
|
-
"footerSessionTokens": false, // 本次对话累计 token
|
|
90
|
+
"footerSessionTokens": false, // 本次对话累计 token 芯片(Nerd Font 图标,纯文本为 Σ);累加会话条目的 usage.totalTokens,默认关
|
|
90
91
|
"footerCurrency": "USD", // 底栏费用的显示币种
|
|
91
92
|
"footerCurrencyRate": null, // 1 单位费用换成多少显示币种;null 只换符号、不乘倍率
|
|
92
|
-
"footerNerdIcons": true, // git
|
|
93
|
+
"footerNerdIcons": true, // git/缓存/token 用 Nerd Font 图标;false 为纯文本
|
|
93
94
|
"footerGitStatsMode": "working", // working(默认):未提交改动;branch:分支累计净改动
|
|
94
95
|
"footerHiddenKeys": [], // 隐藏的芯片 key
|
|
95
96
|
"footerLine1Keys": ["model", "context", "cache", "rate", "cost", "tokens", "pi-usage"], // 每行芯片顺序;内置项与插件同一套排布
|
|
@@ -759,8 +759,8 @@ export async function showCcstylePanel(
|
|
|
759
759
|
id: "footerNerdIcons",
|
|
760
760
|
label: "Nerd Font icons",
|
|
761
761
|
description: config.footerNerdIcons
|
|
762
|
-
? "Git and
|
|
763
|
-
: "Git and
|
|
762
|
+
? "Git, cache and token chips use Nerd Font glyphs. Turn off for plain text."
|
|
763
|
+
: "Git, cache and token chips use plain text. No Nerd Font required.",
|
|
764
764
|
currentValue: config.footerNerdIcons ? "on" : "off",
|
|
765
765
|
values: ["on", "off"],
|
|
766
766
|
};
|
|
@@ -937,8 +937,8 @@ export async function showCcstylePanel(
|
|
|
937
937
|
updateConfig({ footerNerdIcons: enabled });
|
|
938
938
|
footerNerdIconsSetting.currentValue = enabled ? "on" : "off";
|
|
939
939
|
footerNerdIconsSetting.description = enabled
|
|
940
|
-
? "Git and
|
|
941
|
-
: "Git and
|
|
940
|
+
? "Git, cache and token chips use Nerd Font glyphs. Turn off for plain text."
|
|
941
|
+
: "Git, cache and token chips use plain text. No Nerd Font required.";
|
|
942
942
|
ctx.ui.notify(`Updated ${id}: ${value}`, "info");
|
|
943
943
|
return;
|
|
944
944
|
}
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
styleCompactThinkingText,
|
|
27
27
|
} from "../renderer/compact-mode.ts";
|
|
28
28
|
import { refreshMountedTranscript } from "../renderer/transcript-refresh.ts";
|
|
29
|
+
import { isTranscriptComponent } from "../renderer/transcript-scope.ts";
|
|
29
30
|
import {
|
|
30
31
|
COMPACT_THINKING_OWNER,
|
|
31
32
|
COMPACT_THINKING_PATCH_KEY,
|
|
@@ -645,6 +646,11 @@ function compactThinking(pi: ExtensionAPI) {
|
|
|
645
646
|
message: AssistantMessage,
|
|
646
647
|
isStreaming?: boolean,
|
|
647
648
|
) {
|
|
649
|
+
// 浏览层副本:不改写思考块,也不当作最新消息跟踪。
|
|
650
|
+
if (isTranscriptComponent(this)) {
|
|
651
|
+
originalUpdateContent.call(this, message, isStreaming);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
648
654
|
const component = this as AssistantMessageComponentLike;
|
|
649
655
|
const self = this as unknown as AssistantInternals;
|
|
650
656
|
// isStreaming 丢失 → mermaid 流式误渲染来回闪。
|
|
@@ -59,10 +59,26 @@ export const FOOTER_NERD_ICON_CACHE = "";
|
|
|
59
59
|
// md power-plug:MCP 服务器连接数
|
|
60
60
|
export const FOOTER_NERD_ICON_MCP = "\u{f06a5}";
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
// md sigma:会话累计 token。与缓存、MCP 同属 md 图标,字高一致。
|
|
63
|
+
export const FOOTER_NERD_ICON_TOKENS = "\u{f04a0}";
|
|
64
|
+
// 纯文本用希腊字母 Σ:数学求和号 ∑ 在多数字体里比数字高,排在芯片里突兀。
|
|
65
|
+
// 不留空:只剩数字时看不出是 token 总数。
|
|
66
|
+
export const FOOTER_PLAIN_ICON_TOKENS = "Σ";
|
|
67
|
+
|
|
68
|
+
export function footerGlyphs(nerdIcons: boolean): {
|
|
69
|
+
git: string;
|
|
70
|
+
cache: string;
|
|
71
|
+
mcp: string;
|
|
72
|
+
tokens: string;
|
|
73
|
+
} {
|
|
63
74
|
return nerdIcons
|
|
64
|
-
? {
|
|
65
|
-
|
|
75
|
+
? {
|
|
76
|
+
git: FOOTER_NERD_ICON_GIT,
|
|
77
|
+
cache: FOOTER_NERD_ICON_CACHE,
|
|
78
|
+
mcp: FOOTER_NERD_ICON_MCP,
|
|
79
|
+
tokens: FOOTER_NERD_ICON_TOKENS,
|
|
80
|
+
}
|
|
81
|
+
: { git: "", cache: "", mcp: "", tokens: FOOTER_PLAIN_ICON_TOKENS };
|
|
66
82
|
}
|
|
67
83
|
|
|
68
84
|
/**
|
|
@@ -487,7 +503,9 @@ const createCustomFooterFactory =
|
|
|
487
503
|
const costChip = costLabel
|
|
488
504
|
? theme.fg("dim", costLabel) + (usingSubscription ? theme.fg("warning", " sub") : "")
|
|
489
505
|
: "";
|
|
490
|
-
const tokensLabel = config.footerSessionTokens
|
|
506
|
+
const tokensLabel = config.footerSessionTokens
|
|
507
|
+
? formatSessionTokenTotal(tokens, footerGlyphs(config.footerNerdIcons).tokens)
|
|
508
|
+
: "";
|
|
491
509
|
|
|
492
510
|
const gitColor = theme.getThinkingBorderColor("medium");
|
|
493
511
|
let place = theme.fg("accent", cwdLabel);
|
|
@@ -45,8 +45,8 @@ export function sessionEntryTokens(entry: unknown): number {
|
|
|
45
45
|
return 0;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
/**
|
|
49
|
-
export function formatSessionTokenTotal(total: number): string {
|
|
48
|
+
/** 芯片文案:`<glyph> 402k`;没有数据返回空串(不占位置)。 */
|
|
49
|
+
export function formatSessionTokenTotal(total: number, glyph: string): string {
|
|
50
50
|
if (!Number.isFinite(total) || total <= 0) return "";
|
|
51
|
-
return
|
|
51
|
+
return `${glyph} ${formatTokenCount(Math.round(total))}`;
|
|
52
52
|
}
|
|
@@ -60,6 +60,14 @@ import { isToolCallHovered } from "./mouse/hover.ts";
|
|
|
60
60
|
import { getToolMouseTui } from "./mouse/scroll.ts";
|
|
61
61
|
import { expandedBodyComponent, insetComponent } from "./tool/result.ts";
|
|
62
62
|
import { isLiveToolCall, scheduleAnimation } from "./tool/tool-state.ts";
|
|
63
|
+
import { isTranscriptComponent } from "./transcript-scope.ts";
|
|
64
|
+
|
|
65
|
+
const COMPACT_ROUND_TAIL_KEY = Symbol.for("ccstyle.compactRoundTail");
|
|
66
|
+
|
|
67
|
+
/** compact 挂进对话区的回合尾行(摘要行与槽位卡),浏览层跳过它。 */
|
|
68
|
+
export function isCompactRoundTail(value: unknown): boolean {
|
|
69
|
+
return Boolean(value && typeof value === "object" && (value as any)[COMPACT_ROUND_TAIL_KEY]);
|
|
70
|
+
}
|
|
63
71
|
import { renderExpandedToolResult } from "./tool/tool-io.ts";
|
|
64
72
|
import { paddedBackgroundRow } from "./tool/grouping.ts";
|
|
65
73
|
import { styleChangeCounts } from "./tool/ccstyle-tool.ts";
|
|
@@ -1479,15 +1487,22 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1479
1487
|
/**
|
|
1480
1488
|
* 回合还有活干:成员消息仍在流式,或本回合的工具还没拿到最终结果。
|
|
1481
1489
|
* 收尾要么按事实等活干完,要么摘要行就会把仍在跑的工具报成 Ran for。
|
|
1490
|
+
* pendingLiveTool 是「toolCall 已写进消息、组件还没挂上」的空窗:流停了也要等它合上。
|
|
1491
|
+
* 只在 renderRoundLive 里按缺组件重算;空闲回放不会再补组件,不因此停在 Running。
|
|
1482
1492
|
*/
|
|
1483
1493
|
const roundHasLiveWork = (round: CompactRound): boolean => {
|
|
1494
|
+
if (round.pendingLiveTool && !sessionIdle()) return true;
|
|
1484
1495
|
for (const component of round.messages.keys()) {
|
|
1485
1496
|
if (component?.isStreaming === true) return true;
|
|
1486
1497
|
}
|
|
1487
1498
|
const ids = roundToolCallIds(round);
|
|
1488
1499
|
if (ids.size === 0) return false;
|
|
1489
1500
|
for (const tool of trackedToolComponents) {
|
|
1490
|
-
|
|
1501
|
+
const id = tool?.toolCallId;
|
|
1502
|
+
if (typeof id !== "string" || !ids.has(id)) continue;
|
|
1503
|
+
if (isRunningTool(tool)) return true;
|
|
1504
|
+
// 组件已挂上、还没 markExecutionStarted:声明期 id 仍在等派发。
|
|
1505
|
+
if (!sessionIdle() && isLiveToolCall(id) && (!tool.result || tool.isPartial === true)) {
|
|
1491
1506
|
return true;
|
|
1492
1507
|
}
|
|
1493
1508
|
}
|
|
@@ -1625,6 +1640,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1625
1640
|
}
|
|
1626
1641
|
const tail: MountedRoundTail = { host: undefined, container: undefined, parts: [], round };
|
|
1627
1642
|
const host: any = {
|
|
1643
|
+
[COMPACT_ROUND_TAIL_KEY]: true,
|
|
1628
1644
|
outputPad: Number(round.anchor?.outputPad) || 0,
|
|
1629
1645
|
children: [],
|
|
1630
1646
|
render: (width: number) => tail.parts.flatMap((part: any) => part?.render?.(width) ?? []),
|
|
@@ -2287,6 +2303,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
2287
2303
|
return patch.assistantNative.call(this, message, isStreaming);
|
|
2288
2304
|
}
|
|
2289
2305
|
self.lastMessage = message;
|
|
2306
|
+
// 浏览层副本:不进回合跟踪,按原生方式渲染完整消息。
|
|
2307
|
+
if (isTranscriptComponent(this)) return passThroughAssistant(this, message, isStreaming);
|
|
2290
2308
|
trackedAssistantComponents.add(this);
|
|
2291
2309
|
if (!patch.active || config.mode !== "compact") {
|
|
2292
2310
|
return passThroughAssistant(this, message, isStreaming);
|
|
@@ -2431,7 +2449,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
2431
2449
|
};
|
|
2432
2450
|
|
|
2433
2451
|
patch.toolInstalledRender = function (this: any, width: number) {
|
|
2434
|
-
if (!patch.active || config.mode !== "compact") {
|
|
2452
|
+
if (!patch.active || config.mode !== "compact" || isTranscriptComponent(this)) {
|
|
2435
2453
|
return patch.toolOriginalRender.call(this, width);
|
|
2436
2454
|
}
|
|
2437
2455
|
const name = String(this.toolName ?? "");
|
|
@@ -2460,6 +2478,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
2460
2478
|
};
|
|
2461
2479
|
|
|
2462
2480
|
patch.toolInstalledUpdateDisplay = function (this: any) {
|
|
2481
|
+
// 浏览层副本:不受回合展开规则约束,也不进组件跟踪。
|
|
2482
|
+
if (isTranscriptComponent(this)) return patch.toolOriginalUpdateDisplay.call(this);
|
|
2463
2483
|
const id = String(this.toolCallId ?? "");
|
|
2464
2484
|
if (
|
|
2465
2485
|
patch.active &&
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
TOOL_MOUSE_DISABLE,
|
|
32
32
|
} from "./mouse/interaction.ts";
|
|
33
33
|
import { getToolMouseTui, noteNewTranscriptItem } from "./mouse/scroll.ts";
|
|
34
|
+
import { installTranscriptMode, teardownTranscriptMode } from "./transcript-mode.ts";
|
|
34
35
|
import {
|
|
35
36
|
disposeStickyPromptPatches,
|
|
36
37
|
installStickyPromptPatches,
|
|
@@ -249,6 +250,8 @@ export default function (
|
|
|
249
250
|
// 鼠标交互独立于渲染层:fullscreen 渲染层让位(hooks undefined)但
|
|
250
251
|
// 工具点击/回到底部适配仍需安装;保持在渲染层安装之后以维持原顺序。
|
|
251
252
|
if (ctx?.mode === "tui" && ctx?.hasUI) installToolMouseInteraction(ctx, mouseOwner);
|
|
253
|
+
// 浏览模式与鼠标交互同为输入监听,跟着一起装。
|
|
254
|
+
installTranscriptMode(ctx);
|
|
252
255
|
if (!hooks) return;
|
|
253
256
|
hooks.toolGrouping.setTheme(ctx.ui.theme);
|
|
254
257
|
setMessageDisplayTheme(ctx.ui.theme);
|
|
@@ -273,6 +276,7 @@ export default function (
|
|
|
273
276
|
// Compaction rebuilds the transcript without session_start. Rebind after
|
|
274
277
|
// other TUI extensions may have replaced the root input dispatcher.
|
|
275
278
|
if (ctx?.mode === "tui" && ctx?.hasUI) installToolMouseInteraction(ctx, mouseOwner);
|
|
279
|
+
installTranscriptMode(ctx);
|
|
276
280
|
if (!hooks) return;
|
|
277
281
|
hooks.toolGrouping.setTheme(ctx.ui.theme);
|
|
278
282
|
setMessageDisplayTheme(ctx.ui.theme);
|
|
@@ -313,6 +317,7 @@ export default function (
|
|
|
313
317
|
// 鼠标交互独立于渲染层:fullscreen 下 installation 为 undefined,
|
|
314
318
|
// 但 onTerminalInput 监听与 handleViewportInput 包装仍需释放。
|
|
315
319
|
teardownToolMouseInteraction(mouseOwner);
|
|
320
|
+
teardownTranscriptMode();
|
|
316
321
|
const current = installation;
|
|
317
322
|
if (!current || !current.defaultMode.isOwner()) return;
|
|
318
323
|
current.defaultMode.shutdown();
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
6
6
|
import { config, getToolDisplayConfig, type CompactStyleMode } from "../../config/config.ts";
|
|
7
|
+
import { expandedLineLimit } from "../transcript-scope.ts";
|
|
7
8
|
import { isToolCallHovered } from "../mouse/hover.ts";
|
|
8
9
|
import {
|
|
9
10
|
ASK_USER_QUESTION_TOOL_NAME,
|
|
@@ -126,7 +127,7 @@ function renderExpandedTaskResult(
|
|
|
126
127
|
if (toolName === "TaskList") {
|
|
127
128
|
const tasks = parseTaskList(text);
|
|
128
129
|
if (!tasks.length) return undefined;
|
|
129
|
-
const limit = Math.max(1, config.expandedPreviewMaxLines);
|
|
130
|
+
const limit = Math.max(1, expandedLineLimit(config.expandedPreviewMaxLines));
|
|
130
131
|
const rows = tasks.slice(0, limit).map((task) => {
|
|
131
132
|
const color =
|
|
132
133
|
task.status === "completed"
|
|
@@ -197,11 +198,13 @@ function shellDurationText(toolName: string, context: any, isPartial: boolean):
|
|
|
197
198
|
/**
|
|
198
199
|
* 运行中的展开视图:与结束后同一份 Input / Output。
|
|
199
200
|
* 还没输出时用 Pending… 占位:空正文会被视图画成 Done,像已经跑完了。
|
|
201
|
+
* 纯空白(空格、换行、tab)同样算还没输出;非空正文保留原始缩进,不整体 trim。
|
|
200
202
|
* shell 的已用时间挂到视图尾部;取值函数每帧重算,秒数才跟着动画 tick 走。
|
|
201
203
|
*/
|
|
202
204
|
function renderRunningExpandedResult(toolName: string, result: any, context: any, theme: any): any {
|
|
205
|
+
const text = textFromResult(result);
|
|
203
206
|
const view = renderExpandedToolResult(
|
|
204
|
-
|
|
207
|
+
text.trim() ? text : "Pending…",
|
|
205
208
|
theme,
|
|
206
209
|
false,
|
|
207
210
|
context?.lastComponent,
|
|
@@ -450,8 +453,12 @@ export function createCcstyleTool(
|
|
|
450
453
|
cachedWidth = width;
|
|
451
454
|
cachedIcon = icon;
|
|
452
455
|
// 路径按最终可用宽度中间截断,避免整行二次截断隐藏文件名。
|
|
453
|
-
cachedLine =
|
|
454
|
-
|
|
456
|
+
cachedLine = truncateToWidth(
|
|
457
|
+
`${lead}${icon} ${renderToolSummary(summary, mainWidth, theme.fg.bind(theme))}${extraStyled}`,
|
|
458
|
+
viewportWidth,
|
|
459
|
+
"",
|
|
460
|
+
);
|
|
461
|
+
return [cachedLine];
|
|
455
462
|
},
|
|
456
463
|
invalidate() {
|
|
457
464
|
cachedLine = undefined;
|
|
@@ -517,6 +524,8 @@ export function createCcstyleTool(
|
|
|
517
524
|
if (options?.isPartial) {
|
|
518
525
|
// 等待回答时也能展开看问题原文(Input 即 args.questions);折叠态 1 格缩进。
|
|
519
526
|
if (expanded) return renderRunningExpandedResult(toolName, result, context, theme);
|
|
527
|
+
// 折叠后清掉展开视图,与通用 partial 折叠一致。
|
|
528
|
+
if (context?.state) context.state.ccstyleIoView = undefined;
|
|
520
529
|
return renderRunningCollapsedResult({ toolName, context, theme, prefix: " ↳ " });
|
|
521
530
|
}
|
|
522
531
|
if (expanded) {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { highlightCode } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
13
13
|
import { config } from "../../config/config.ts";
|
|
14
|
+
import { expandedLineLimit } from "../transcript-scope.ts";
|
|
14
15
|
import { stripAnsi } from "../../utils/ansi-text.ts";
|
|
15
16
|
import { oneLine } from "../../utils/format.ts";
|
|
16
17
|
import { toolLoadingIcon } from "../../utils/tool-loading-icon.ts";
|
|
@@ -164,7 +165,7 @@ export function codemodeCodeLines(
|
|
|
164
165
|
if (text) statements.push(text);
|
|
165
166
|
}
|
|
166
167
|
if (statements.length === 0) return [];
|
|
167
|
-
const limit = Math.max(1, config.expandedInputMaxLines);
|
|
168
|
+
const limit = Math.max(1, expandedLineLimit(config.expandedInputMaxLines));
|
|
168
169
|
const visible = statements.slice(0, limit);
|
|
169
170
|
const rail = `${INDENT}${fg("dim", "│")} `;
|
|
170
171
|
const lines = visible.map((statement) =>
|
|
@@ -53,8 +53,8 @@ function renderDiffStatBar(stats: DiffStats, width: number, theme: DiffTheme): s
|
|
|
53
53
|
}
|
|
54
54
|
const removedSlots = Math.max(0, barSlots - addedSlots);
|
|
55
55
|
|
|
56
|
-
const addedBar = addedSlots > 0 ? theme.fg("
|
|
57
|
-
const removedBar = removedSlots > 0 ? theme.fg("
|
|
56
|
+
const addedBar = addedSlots > 0 ? theme.fg("success", "━".repeat(addedSlots)) : "";
|
|
57
|
+
const removedBar = removedSlots > 0 ? theme.fg("error", "━".repeat(removedSlots)) : "";
|
|
58
58
|
return stabilizeBackgroundResets(
|
|
59
59
|
`${theme.fg("dim", "[")}${addedBar}${removedBar}${theme.fg("dim", "]")}`,
|
|
60
60
|
);
|
|
@@ -63,8 +63,8 @@ function renderDiffStatBar(stats: DiffStats, width: number, theme: DiffTheme): s
|
|
|
63
63
|
function buildDiffSummaryBasePieces(stats: DiffStats, theme: DiffTheme): string[] {
|
|
64
64
|
return [
|
|
65
65
|
theme.fg("toolOutput", `↳ ${emphasis(theme, "diff")}`),
|
|
66
|
-
theme.fg("
|
|
67
|
-
theme.fg("
|
|
66
|
+
theme.fg("success", `+${stats.added}`),
|
|
67
|
+
theme.fg("error", `-${stats.removed}`),
|
|
68
68
|
];
|
|
69
69
|
}
|
|
70
70
|
|
|
@@ -92,10 +92,10 @@ function renderChangeMarker(
|
|
|
92
92
|
return rowBg ? `${rowBg} ${rowBg}` : " ";
|
|
93
93
|
}
|
|
94
94
|
if (kind === "add") {
|
|
95
|
-
return colorizeSegment(theme, "
|
|
95
|
+
return colorizeSegment(theme, "success", glyph, rowBg);
|
|
96
96
|
}
|
|
97
97
|
if (kind === "remove") {
|
|
98
|
-
return colorizeSegment(theme, "
|
|
98
|
+
return colorizeSegment(theme, "error", glyph, rowBg);
|
|
99
99
|
}
|
|
100
100
|
return colorizeSegment(theme, "dim", glyph, rowBg);
|
|
101
101
|
}
|
|
@@ -132,12 +132,12 @@ function renderCodeDivider(
|
|
|
132
132
|
);
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
function getLineNumberColor(kind: DiffLineKind): "dim" | "
|
|
135
|
+
function getLineNumberColor(kind: DiffLineKind): "dim" | "success" | "error" {
|
|
136
136
|
if (kind === "add") {
|
|
137
|
-
return "
|
|
137
|
+
return "success";
|
|
138
138
|
}
|
|
139
139
|
if (kind === "remove") {
|
|
140
|
-
return "
|
|
140
|
+
return "error";
|
|
141
141
|
}
|
|
142
142
|
return "dim";
|
|
143
143
|
}
|
|
@@ -179,7 +179,7 @@ function renderClassicContentPrefix(
|
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
const glyph = kind === "add" ? "+" : "-";
|
|
182
|
-
const glyphColor = kind === "add" ? "
|
|
182
|
+
const glyphColor = kind === "add" ? "success" : "error";
|
|
183
183
|
const spacer = rowBg ? `${rowBg} ` : " ";
|
|
184
184
|
return `${colorizeSegment(theme, glyphColor, glyph, rowBg)}${spacer}`;
|
|
185
185
|
}
|
|
@@ -115,7 +115,7 @@ export function keepBackgroundAcrossResets(text: string, rowBg: string): string
|
|
|
115
115
|
|
|
116
116
|
export function colorizeSegment(
|
|
117
117
|
theme: DiffTheme,
|
|
118
|
-
color: "dim" | "
|
|
118
|
+
color: "dim" | "success" | "error",
|
|
119
119
|
text: string,
|
|
120
120
|
rowBg: string | undefined,
|
|
121
121
|
): string {
|
|
@@ -276,15 +276,15 @@ export function readThemeAnsi(
|
|
|
276
276
|
export function resolveDiffPalette(theme: DiffTheme): DiffPalette {
|
|
277
277
|
const fallbackBg = parseAnsiColorCode(readThemeAnsi(theme, "bg", "toolPendingBg")) ??
|
|
278
278
|
parseAnsiColorCode(readThemeAnsi(theme, "bg", "userMessageBg")) ?? { r: 32, g: 35, b: 42 };
|
|
279
|
-
//
|
|
279
|
+
// 新增行/删除行的文字和底色统一取 success、error,与工具标题、footer 计数同源。
|
|
280
280
|
const addBaseBg = parseAnsiColorCode(readThemeAnsi(theme, "bg", "toolSuccessBg")) ?? fallbackBg;
|
|
281
281
|
const removeBaseBg = parseAnsiColorCode(readThemeAnsi(theme, "bg", "toolErrorBg")) ?? addBaseBg;
|
|
282
|
-
const addFg = parseAnsiColorCode(readThemeAnsi(theme, "fg", "
|
|
282
|
+
const addFg = parseAnsiColorCode(readThemeAnsi(theme, "fg", "success")) ?? {
|
|
283
283
|
r: 88,
|
|
284
284
|
g: 173,
|
|
285
285
|
b: 88,
|
|
286
286
|
};
|
|
287
|
-
const removeFg = parseAnsiColorCode(readThemeAnsi(theme, "fg", "
|
|
287
|
+
const removeFg = parseAnsiColorCode(readThemeAnsi(theme, "fg", "error")) ?? {
|
|
288
288
|
r: 196,
|
|
289
289
|
g: 98,
|
|
290
290
|
b: 98,
|
|
@@ -96,9 +96,9 @@ export function styleDiffSummaryLine(
|
|
|
96
96
|
};
|
|
97
97
|
if (countsIndex >= 0 && stats) {
|
|
98
98
|
pushMuted(countsIndex);
|
|
99
|
-
pieces.push(theme.fg("
|
|
99
|
+
pieces.push(theme.fg("success", `+${stats.added}`));
|
|
100
100
|
pieces.push(theme.fg("muted", " "));
|
|
101
|
-
pieces.push(theme.fg("
|
|
101
|
+
pieces.push(theme.fg("error", `-${stats.removed}`));
|
|
102
102
|
cursor = countsIndex + counts.length;
|
|
103
103
|
}
|
|
104
104
|
if (hintIndex >= cursor) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Text, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
2
|
import { config } from "../../config/config.ts";
|
|
3
|
+
import { expandedLineLimit } from "../transcript-scope.ts";
|
|
3
4
|
import { sanitizeToolResultText } from "../../utils/tool-result-sanitize.ts";
|
|
4
5
|
import {
|
|
5
6
|
SHOW_MORE_LABEL,
|
|
@@ -648,8 +649,8 @@ export function renderExpandedToolResult(
|
|
|
648
649
|
): ExpandedToolIoView | ExpandedToolResultText | Text {
|
|
649
650
|
const { text: inputBody, codeBlock } = formatToolInputBody(args);
|
|
650
651
|
const outputBody = body;
|
|
651
|
-
const maxOutputLines = config.expandedOutputMaxLines;
|
|
652
|
-
const maxInputLines = config.expandedInputMaxLines;
|
|
652
|
+
const maxOutputLines = expandedLineLimit(config.expandedOutputMaxLines, context);
|
|
653
|
+
const maxInputLines = expandedLineLimit(config.expandedInputMaxLines, context);
|
|
653
654
|
|
|
654
655
|
// Prefer structured Input/Output when we have args or non-empty output.
|
|
655
656
|
if (inputBody.trim() || outputBody.trim()) {
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话记录浏览层:对齐 Claude Code 的 transcript 视图。
|
|
3
|
+
*
|
|
4
|
+
* on / compact 模式、全屏、输入框有焦点时按「展开工具输出」键(默认 ctrl+o),
|
|
5
|
+
* 打开一个铺满屏幕的弹层,盖住对话区、输入框和底栏。弹层持有焦点,按键只用来翻阅。
|
|
6
|
+
*
|
|
7
|
+
* 弹层不走对话区的渲染流程:照着对话区现有组件新建一份副本来画。
|
|
8
|
+
* - 工具卡:按原参数与结果新建 ToolExecutionComponent,全部展开;
|
|
9
|
+
* - 模型回复:按原消息新建 AssistantMessageComponent,思考块完整显示;
|
|
10
|
+
* - 其余(用户消息、提示、摘要):不受展开影响,直接画原组件;
|
|
11
|
+
* - compact 挂进对话区的回合尾行:跳过。
|
|
12
|
+
* 副本带浏览标记(见 transcript-scope),compact 折叠、分组、互斥收起与行数上限都不作用于它们。
|
|
13
|
+
* 对话区本身一个状态都不改,关掉弹层即恢复原样。
|
|
14
|
+
*
|
|
15
|
+
* Pi 把 app.tools.expand 列为保留键,registerShortcut 注册不上;这里在原始输入到达
|
|
16
|
+
* 输入框之前拦截。只认 Pi 自己会响应该键的组件(带 actionHandlers 的编辑器)。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
AssistantMessageComponent,
|
|
21
|
+
ToolExecutionComponent,
|
|
22
|
+
UserMessageComponent,
|
|
23
|
+
} from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import { getKeybindings, isKeyRelease, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
25
|
+
import { config } from "../config/config.ts";
|
|
26
|
+
import { walkComponentTree } from "../utils/component-tree.ts";
|
|
27
|
+
import { isCompactRoundTail } from "./compact-mode.ts";
|
|
28
|
+
import { stripToolFrameMarkers } from "./mouse/packets.ts";
|
|
29
|
+
import { fullscreenLazyTui, getToolMouseTui } from "./mouse/scroll.ts";
|
|
30
|
+
import { ToolGroupComponent } from "./tool/grouping.ts";
|
|
31
|
+
import { expandShortcutText } from "./tool/names.ts";
|
|
32
|
+
import { markTranscriptComponent, withTranscriptScope } from "./transcript-scope.ts";
|
|
33
|
+
|
|
34
|
+
const EXPAND_ACTION = "app.tools.expand";
|
|
35
|
+
// 与 Pi 的 tui.altScreen.pageUp/pageDown 同一重叠行数,翻页后保留上下文。
|
|
36
|
+
const PAGE_SCROLL_OVERLAP = 4;
|
|
37
|
+
const WHEEL_LINES = 3;
|
|
38
|
+
|
|
39
|
+
type TranscriptUi = {
|
|
40
|
+
onTerminalInput(handler: (data: string) => { consume?: boolean } | undefined): () => void;
|
|
41
|
+
custom<T>(factory: (...args: any[]) => any, options?: any): Promise<T>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type ScrollAction =
|
|
45
|
+
| { kind: "lines"; lines: number }
|
|
46
|
+
| { kind: "half"; direction: 1 | -1 }
|
|
47
|
+
| { kind: "page"; direction: 1 | -1 }
|
|
48
|
+
| { kind: "top" }
|
|
49
|
+
| { kind: "bottom" }
|
|
50
|
+
| { kind: "prompt"; direction: 1 | -1 };
|
|
51
|
+
|
|
52
|
+
const SCROLL_KEYS: Array<[string[], ScrollAction]> = [
|
|
53
|
+
[["j", "down", "ctrl+n"], { kind: "lines", lines: 1 }],
|
|
54
|
+
[["k", "up", "ctrl+p"], { kind: "lines", lines: -1 }],
|
|
55
|
+
[["ctrl+d"], { kind: "half", direction: 1 }],
|
|
56
|
+
[["ctrl+u"], { kind: "half", direction: -1 }],
|
|
57
|
+
[["space", "ctrl+f", "pageDown"], { kind: "page", direction: 1 }],
|
|
58
|
+
[["b", "ctrl+b", "shift+space", "pageUp"], { kind: "page", direction: -1 }],
|
|
59
|
+
[["g", "home"], { kind: "top" }],
|
|
60
|
+
[["shift+g", "end"], { kind: "bottom" }],
|
|
61
|
+
[["}"], { kind: "prompt", direction: 1 }],
|
|
62
|
+
[["{"], { kind: "prompt", direction: -1 }],
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
const EXIT_KEYS = ["escape", "q", "ctrl+c"];
|
|
66
|
+
|
|
67
|
+
function matchesAny(data: string, keys: string[]): boolean {
|
|
68
|
+
return keys.some((key) => matchesKey(data, key as any));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function transcriptScrollAction(data: string): ScrollAction | undefined {
|
|
72
|
+
for (const [keys, action] of SCROLL_KEYS) {
|
|
73
|
+
if (matchesAny(data, keys)) return action;
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function isTranscriptExitKey(data: string): boolean {
|
|
79
|
+
return matchesAny(data, EXIT_KEYS) || getKeybindings().matches(data, EXPAND_ACTION as any);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// 副本
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
type TranscriptItem = { view: any; prompt: boolean };
|
|
87
|
+
type CloneEntry = { view: any; signature: unknown[] };
|
|
88
|
+
|
|
89
|
+
const clones = new WeakMap<object, CloneEntry>();
|
|
90
|
+
|
|
91
|
+
function sameSignature(left: unknown[], right: unknown[]): boolean {
|
|
92
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function reuseOrClone(live: any, signature: unknown[], build: () => any): any {
|
|
96
|
+
const cached = clones.get(live);
|
|
97
|
+
if (cached && sameSignature(cached.signature, signature)) return cached.view;
|
|
98
|
+
const view = withTranscriptScope(build);
|
|
99
|
+
markTranscriptComponent(view);
|
|
100
|
+
clones.set(live, { view, signature });
|
|
101
|
+
return view;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function cloneTool(live: any): any {
|
|
105
|
+
const signature = [
|
|
106
|
+
live.args,
|
|
107
|
+
live.result,
|
|
108
|
+
live.isPartial,
|
|
109
|
+
live.executionStarted,
|
|
110
|
+
live.argsComplete,
|
|
111
|
+
];
|
|
112
|
+
return reuseOrClone(live, signature, () => {
|
|
113
|
+
const view: any = new ToolExecutionComponent(
|
|
114
|
+
live.toolName,
|
|
115
|
+
live.toolCallId,
|
|
116
|
+
live.args,
|
|
117
|
+
{
|
|
118
|
+
showImages: live.showImages,
|
|
119
|
+
imageWidthCells: live.imageWidthCells,
|
|
120
|
+
outputPad: live.outputPad,
|
|
121
|
+
},
|
|
122
|
+
live.toolDefinition,
|
|
123
|
+
live.ui,
|
|
124
|
+
live.cwd,
|
|
125
|
+
);
|
|
126
|
+
if (live.argsComplete) view.setArgsComplete();
|
|
127
|
+
if (live.executionStarted) view.markExecutionStarted();
|
|
128
|
+
if (live.result) view.updateResult(live.result, live.isPartial === true);
|
|
129
|
+
view.setExpanded(true);
|
|
130
|
+
return view;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function cloneAssistant(live: any): any {
|
|
135
|
+
const message = live.lastMessage;
|
|
136
|
+
if (!message) return undefined;
|
|
137
|
+
return reuseOrClone(live, [message, live.isStreaming], () => {
|
|
138
|
+
const view: any = new AssistantMessageComponent(
|
|
139
|
+
undefined,
|
|
140
|
+
false,
|
|
141
|
+
live.markdownTheme,
|
|
142
|
+
live.hiddenThinkingLabel,
|
|
143
|
+
live.outputPad,
|
|
144
|
+
live.markdownTransformers,
|
|
145
|
+
);
|
|
146
|
+
view.updateContent(message, live.isStreaming === true);
|
|
147
|
+
return view;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 对话区(Pi 的 chatContainer):直接装着回复或用户消息的那个容器。 */
|
|
152
|
+
export function findChatContainer(root: any): any {
|
|
153
|
+
let found: any;
|
|
154
|
+
walkComponentTree(root, (value: any) => {
|
|
155
|
+
if (found) return false;
|
|
156
|
+
const children = value?.children;
|
|
157
|
+
if (
|
|
158
|
+
Array.isArray(children) &&
|
|
159
|
+
children.some(
|
|
160
|
+
(child: any) =>
|
|
161
|
+
child instanceof AssistantMessageComponent || child instanceof UserMessageComponent,
|
|
162
|
+
)
|
|
163
|
+
) {
|
|
164
|
+
found = value;
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
return undefined;
|
|
168
|
+
});
|
|
169
|
+
return found;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 按对话区现有内容生成浏览项:工具与回复用副本,其余直接用原组件。 */
|
|
173
|
+
export function collectTranscriptItems(container: any): TranscriptItem[] {
|
|
174
|
+
const items: TranscriptItem[] = [];
|
|
175
|
+
const pushTool = (tool: any) => {
|
|
176
|
+
if (tool instanceof ToolExecutionComponent)
|
|
177
|
+
items.push({ view: cloneTool(tool), prompt: false });
|
|
178
|
+
};
|
|
179
|
+
for (const child of Array.isArray(container?.children) ? container.children : []) {
|
|
180
|
+
if (isCompactRoundTail(child)) continue;
|
|
181
|
+
if (child instanceof ToolGroupComponent) {
|
|
182
|
+
for (const tool of child.children) pushTool(tool);
|
|
183
|
+
} else if (child instanceof ToolExecutionComponent) {
|
|
184
|
+
pushTool(child);
|
|
185
|
+
} else if (child instanceof AssistantMessageComponent) {
|
|
186
|
+
const view = cloneAssistant(child);
|
|
187
|
+
if (view) items.push({ view, prompt: false });
|
|
188
|
+
} else if (child && typeof child.render === "function") {
|
|
189
|
+
items.push({ view: child, prompt: child instanceof UserMessageComponent });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return items;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// 弹层
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
export type TranscriptViewOptions = {
|
|
200
|
+
rows: () => number;
|
|
201
|
+
items: () => TranscriptItem[];
|
|
202
|
+
theme: any;
|
|
203
|
+
close: () => void;
|
|
204
|
+
focused: (view: object) => boolean;
|
|
205
|
+
/** 正文滚动了 delta 行(正数为向下翻),bodyHeight 为正文可见行数。 */
|
|
206
|
+
scrolled?: (delta: number, bodyHeight: number) => void;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
type ScreenPoint = { row: number; col: number; scrollView?: unknown };
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 让 Pi 的屏幕选区跟着浏览层的正文走。
|
|
213
|
+
*
|
|
214
|
+
* 有弹层时 Pi 的选区记的是屏幕行号(不挂 scrollView),复制也只从屏幕文字里取。
|
|
215
|
+
* 正文滚动 delta 行后把选区上移 delta 行,高亮与复制都跟着原来的文字。
|
|
216
|
+
* 选区有一端滚出正文区:屏幕上已没有那段文字,清掉选区,免得复制到别的内容。
|
|
217
|
+
*/
|
|
218
|
+
export function followScreenSelection(tui: any, delta: number, bodyHeight: number): void {
|
|
219
|
+
if (!tui || delta === 0) return;
|
|
220
|
+
const anchor: ScreenPoint | undefined = tui.selectionAnchor;
|
|
221
|
+
const focus: ScreenPoint | undefined = tui.selectionFocus;
|
|
222
|
+
if (!anchor || !focus || anchor.scrollView || focus.scrollView) return;
|
|
223
|
+
const move = (point: ScreenPoint): ScreenPoint => ({ ...point, row: point.row - delta });
|
|
224
|
+
const nextAnchor = move(anchor);
|
|
225
|
+
const nextFocus = move(focus);
|
|
226
|
+
const inside = (point: ScreenPoint) => point.row >= 0 && point.row < bodyHeight;
|
|
227
|
+
if (!inside(nextAnchor) || !inside(nextFocus)) {
|
|
228
|
+
tui.resetTextSelection?.();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
tui.selectionAnchor = nextAnchor;
|
|
232
|
+
tui.selectionFocus = nextFocus;
|
|
233
|
+
const range = tui.selectionInitialRange;
|
|
234
|
+
if (range?.start && range?.end) {
|
|
235
|
+
tui.selectionInitialRange = { start: move(range.start), end: move(range.end) };
|
|
236
|
+
}
|
|
237
|
+
// 双击/三击按坐标累计:位置已变,不再和上一次点击连成多击。
|
|
238
|
+
tui.lastClick = undefined;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export class TranscriptView {
|
|
242
|
+
private scrollTop = 0;
|
|
243
|
+
private followEnd = true;
|
|
244
|
+
private bodyHeight = 1;
|
|
245
|
+
private totalLines = 0;
|
|
246
|
+
private promptRows: number[] = [];
|
|
247
|
+
private hadFocus = false;
|
|
248
|
+
private closed = false;
|
|
249
|
+
/** 上一帧画出的滚动位置:与本帧不同就通知选区跟随。 */
|
|
250
|
+
private renderedScrollTop: number | undefined;
|
|
251
|
+
|
|
252
|
+
private readonly options: TranscriptViewOptions;
|
|
253
|
+
|
|
254
|
+
constructor(options: TranscriptViewOptions) {
|
|
255
|
+
this.options = options;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private maxScroll(): number {
|
|
259
|
+
return Math.max(0, this.totalLines - this.bodyHeight);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private scrollTo(row: number): void {
|
|
263
|
+
this.scrollTop = Math.max(0, Math.min(this.maxScroll(), row));
|
|
264
|
+
this.followEnd = this.scrollTop >= this.maxScroll();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private scrollBy(lines: number): void {
|
|
268
|
+
this.scrollTo(this.scrollTop + lines);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private apply(action: ScrollAction): void {
|
|
272
|
+
switch (action.kind) {
|
|
273
|
+
case "lines":
|
|
274
|
+
this.scrollBy(action.lines);
|
|
275
|
+
break;
|
|
276
|
+
case "half":
|
|
277
|
+
this.scrollBy(action.direction * Math.max(1, Math.floor(this.bodyHeight / 2)));
|
|
278
|
+
break;
|
|
279
|
+
case "page":
|
|
280
|
+
this.scrollBy(action.direction * Math.max(1, this.bodyHeight - PAGE_SCROLL_OVERLAP));
|
|
281
|
+
break;
|
|
282
|
+
case "top":
|
|
283
|
+
this.scrollTo(0);
|
|
284
|
+
break;
|
|
285
|
+
case "bottom":
|
|
286
|
+
this.scrollTo(this.maxScroll());
|
|
287
|
+
break;
|
|
288
|
+
case "prompt": {
|
|
289
|
+
const target =
|
|
290
|
+
action.direction > 0
|
|
291
|
+
? this.promptRows.find((row) => row > this.scrollTop)
|
|
292
|
+
: this.promptRows.findLast((row) => row < this.scrollTop);
|
|
293
|
+
if (target !== undefined) this.scrollTo(target);
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private close(): void {
|
|
300
|
+
if (this.closed) return;
|
|
301
|
+
this.closed = true;
|
|
302
|
+
this.options.close();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
handleInput(data: string): void {
|
|
306
|
+
if (isKeyRelease(data)) return;
|
|
307
|
+
if (isTranscriptExitKey(data)) {
|
|
308
|
+
this.close();
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const action = transcriptScrollAction(data);
|
|
312
|
+
if (action) this.apply(action);
|
|
313
|
+
// 其余按键不做任何事:浏览层不编辑输入框。
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* 只接管滚轮。按下、拖动、松开不处理:Pi 有弹层时按屏幕文字做选中复制,
|
|
318
|
+
* 放行后浏览层里的文字照常能拖选、双击选词、复制。
|
|
319
|
+
*/
|
|
320
|
+
handleMouse(event: any): { handled: boolean } | undefined {
|
|
321
|
+
if (event?.type !== "wheel") return undefined;
|
|
322
|
+
const delta = Number(event.wheelDelta) || 0;
|
|
323
|
+
if (delta !== 0) this.scrollBy(Math.sign(delta) * WHEEL_LINES);
|
|
324
|
+
return { handled: true };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
render(width: number): string[] {
|
|
328
|
+
// 其他界面抢走焦点(提问、权限确认等对话框):让位给它。
|
|
329
|
+
if (this.options.focused(this)) this.hadFocus = true;
|
|
330
|
+
else if (this.hadFocus) queueMicrotask(() => this.close());
|
|
331
|
+
|
|
332
|
+
// 底部两行:空一行隔开正文,下面是提示。
|
|
333
|
+
const rows = Math.max(3, this.options.rows());
|
|
334
|
+
this.bodyHeight = rows - 2;
|
|
335
|
+
const lines: string[] = [];
|
|
336
|
+
const promptRows: number[] = [];
|
|
337
|
+
withTranscriptScope(() => {
|
|
338
|
+
for (const item of this.options.items()) {
|
|
339
|
+
if (item.prompt) promptRows.push(lines.length);
|
|
340
|
+
for (const line of item.view.render(width)) lines.push(stripToolFrameMarkers(line));
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
this.totalLines = lines.length;
|
|
344
|
+
this.promptRows = promptRows;
|
|
345
|
+
if (this.followEnd) this.scrollTop = this.maxScroll();
|
|
346
|
+
else this.scrollTop = Math.min(this.scrollTop, this.maxScroll());
|
|
347
|
+
// 按键、滚轮、跟随新内容都会改滚动位置,统一在画出时结算一次。
|
|
348
|
+
if (this.renderedScrollTop !== undefined && this.renderedScrollTop !== this.scrollTop) {
|
|
349
|
+
this.options.scrolled?.(this.scrollTop - this.renderedScrollTop, this.bodyHeight);
|
|
350
|
+
}
|
|
351
|
+
this.renderedScrollTop = this.scrollTop;
|
|
352
|
+
|
|
353
|
+
const body = lines.slice(this.scrollTop, this.scrollTop + this.bodyHeight);
|
|
354
|
+
while (body.length < this.bodyHeight) body.push("");
|
|
355
|
+
return [...body, "", this.hintLine(width)];
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private hintLine(width: number): string {
|
|
359
|
+
const theme = this.options.theme;
|
|
360
|
+
const dim = (text: string) => (typeof theme?.fg === "function" ? theme.fg("dim", text) : text);
|
|
361
|
+
const position =
|
|
362
|
+
this.totalLines > this.bodyHeight
|
|
363
|
+
? ` (${Math.min(100, Math.round(((this.scrollTop + this.bodyHeight) / this.totalLines) * 100))}%)`
|
|
364
|
+
: "";
|
|
365
|
+
// 窄屏从右侧截断:位置与退出方式放在最前。
|
|
366
|
+
return truncateToWidth(
|
|
367
|
+
dim(
|
|
368
|
+
` Showing detailed transcript${position} · esc/q to exit · ${expandShortcutText()} to toggle · j/k scroll`,
|
|
369
|
+
),
|
|
370
|
+
width,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
invalidate(): void {}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
// 入口
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
let ui: TranscriptUi | undefined;
|
|
382
|
+
let unsubscribe: (() => void) | undefined;
|
|
383
|
+
let open = false;
|
|
384
|
+
let closeOpen: (() => void) | undefined;
|
|
385
|
+
|
|
386
|
+
/** 浏览层是否打开。 */
|
|
387
|
+
export function isTranscriptModeActive(): boolean {
|
|
388
|
+
return open;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Pi 会把展开键交给它处理的组件:默认编辑器与继承 CustomEditor 的编辑器。 */
|
|
392
|
+
function editorFocused(tui: any): boolean {
|
|
393
|
+
const handlers = tui?.focusedComponent?.actionHandlers;
|
|
394
|
+
return handlers instanceof Map && handlers.has(EXPAND_ACTION);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function canOpen(tui: any): boolean {
|
|
398
|
+
return (
|
|
399
|
+
config.mode !== "off" &&
|
|
400
|
+
fullscreenLazyTui(tui) &&
|
|
401
|
+
tui.hasOverlay?.() !== true &&
|
|
402
|
+
editorFocused(tui)
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function openTranscript(): void {
|
|
407
|
+
if (!ui || open) return;
|
|
408
|
+
open = true;
|
|
409
|
+
void ui
|
|
410
|
+
.custom<void>(
|
|
411
|
+
(tui: any, theme: any, _keybindings: unknown, done: () => void) => {
|
|
412
|
+
closeOpen = done;
|
|
413
|
+
return new TranscriptView({
|
|
414
|
+
rows: () => Number(tui?.terminal?.rows) || 24,
|
|
415
|
+
items: () => collectTranscriptItems(findChatContainer(getToolMouseTui() ?? tui)),
|
|
416
|
+
theme,
|
|
417
|
+
close: done,
|
|
418
|
+
focused: (view) => tui?.focusedComponent === view,
|
|
419
|
+
scrolled: (delta, bodyHeight) => followScreenSelection(tui, delta, bodyHeight),
|
|
420
|
+
});
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
overlay: true,
|
|
424
|
+
overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%", margin: 0 },
|
|
425
|
+
},
|
|
426
|
+
)
|
|
427
|
+
.finally(() => {
|
|
428
|
+
open = false;
|
|
429
|
+
closeOpen = undefined;
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function handleInput(data: string): { consume?: boolean } | undefined {
|
|
434
|
+
if (open) return undefined;
|
|
435
|
+
if (!getKeybindings().matches(data, EXPAND_ACTION as any)) return undefined;
|
|
436
|
+
if (!canOpen(getToolMouseTui())) return undefined;
|
|
437
|
+
if (!isKeyRelease(data)) openTranscript();
|
|
438
|
+
return { consume: true };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** 装上展开键拦截;重复安装先拆旧的。 */
|
|
442
|
+
export function installTranscriptMode(ctx: any): void {
|
|
443
|
+
teardownTranscriptMode();
|
|
444
|
+
if (ctx?.mode !== "tui" || !ctx?.hasUI) return;
|
|
445
|
+
const candidate = ctx.ui as TranscriptUi | undefined;
|
|
446
|
+
if (typeof candidate?.onTerminalInput !== "function" || typeof candidate.custom !== "function")
|
|
447
|
+
return;
|
|
448
|
+
ui = candidate;
|
|
449
|
+
unsubscribe = candidate.onTerminalInput(handleInput);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function teardownTranscriptMode(): void {
|
|
453
|
+
closeOpen?.();
|
|
454
|
+
unsubscribe?.();
|
|
455
|
+
unsubscribe = undefined;
|
|
456
|
+
ui = undefined;
|
|
457
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话记录浏览层的组件标记。
|
|
3
|
+
*
|
|
4
|
+
* 浏览层照着对话区现有组件新建一份来画。这些副本要绕开对话区的渲染规则:
|
|
5
|
+
* compact 的整轮折叠与组件跟踪、compact-thinking 的思考跟踪、展开后的行数上限。
|
|
6
|
+
*
|
|
7
|
+
* 副本的构造与渲染都包在 withTranscriptScope 里;构造完成后再用
|
|
8
|
+
* markTranscriptComponent 打上实例标记,覆盖动画定时器等在作用域外触发的重绘。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const TRANSCRIPT_STATE_KEY = "ccstyleTranscript";
|
|
12
|
+
const marked = new WeakSet<object>();
|
|
13
|
+
let depth = 0;
|
|
14
|
+
|
|
15
|
+
/** 在浏览层作用域里执行:期间构造或渲染的组件都按浏览副本处理。 */
|
|
16
|
+
export function withTranscriptScope<T>(run: () => T): T {
|
|
17
|
+
depth++;
|
|
18
|
+
try {
|
|
19
|
+
return run();
|
|
20
|
+
} finally {
|
|
21
|
+
depth--;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 给浏览副本打实例标记;渲染状态里也记一份,供只拿得到 context 的渲染器判断。 */
|
|
26
|
+
export function markTranscriptComponent(component: any): void {
|
|
27
|
+
if (!component || typeof component !== "object") return;
|
|
28
|
+
marked.add(component);
|
|
29
|
+
const state = component.rendererState;
|
|
30
|
+
if (state && typeof state === "object") state[TRANSCRIPT_STATE_KEY] = true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 组件是否为浏览副本(或正处于浏览层作用域)。 */
|
|
34
|
+
export function isTranscriptComponent(component: unknown): boolean {
|
|
35
|
+
return (
|
|
36
|
+
depth > 0 || (typeof component === "object" && component !== null && marked.has(component))
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 展开内容的行数上限:浏览副本不限行数,其余沿用配置。 */
|
|
41
|
+
export function expandedLineLimit(limit: number, context?: any): number {
|
|
42
|
+
if (depth > 0 || context?.state?.[TRANSCRIPT_STATE_KEY] === true) return Number.MAX_SAFE_INTEGER;
|
|
43
|
+
return limit;
|
|
44
|
+
}
|
package/package.json
CHANGED
package/themes/cc-dark-hc.json
CHANGED
|
@@ -22,9 +22,7 @@
|
|
|
22
22
|
"purple": "#be8fff",
|
|
23
23
|
"pink": "#f778ba",
|
|
24
24
|
"cyan": "#39c5cf",
|
|
25
|
-
"selection": "#1a4480"
|
|
26
|
-
"diffAddBg": "#0f2a24",
|
|
27
|
-
"diffDelBg": "#261418"
|
|
25
|
+
"selection": "#1a4480"
|
|
28
26
|
},
|
|
29
27
|
"colors": {
|
|
30
28
|
"accent": "blue",
|
|
@@ -32,7 +30,7 @@
|
|
|
32
30
|
"borderAccent": "blue",
|
|
33
31
|
"borderMuted": "borderMuted",
|
|
34
32
|
"success": "green",
|
|
35
|
-
"error": "
|
|
33
|
+
"error": "red",
|
|
36
34
|
"warning": "yellow",
|
|
37
35
|
"muted": "fgMuted",
|
|
38
36
|
"dim": "#71717a",
|
package/themes/cc-dark.json
CHANGED
|
@@ -22,9 +22,7 @@
|
|
|
22
22
|
"purple": "#be8fff",
|
|
23
23
|
"pink": "#f778ba",
|
|
24
24
|
"cyan": "#39c5cf",
|
|
25
|
-
"selection": "#1a4480"
|
|
26
|
-
"diffAddBg": "#0f2a24",
|
|
27
|
-
"diffDelBg": "#261418"
|
|
25
|
+
"selection": "#1a4480"
|
|
28
26
|
},
|
|
29
27
|
"colors": {
|
|
30
28
|
"accent": "blue",
|
|
@@ -49,8 +47,8 @@
|
|
|
49
47
|
"customMessageText": "fgMuted",
|
|
50
48
|
"customMessageLabel": "blue",
|
|
51
49
|
"toolPendingBg": "canvasSubtle",
|
|
52
|
-
"toolSuccessBg": "
|
|
53
|
-
"toolErrorBg": "
|
|
50
|
+
"toolSuccessBg": "#0f2a24",
|
|
51
|
+
"toolErrorBg": "#261418",
|
|
54
52
|
"toolTitle": "fg",
|
|
55
53
|
"toolOutput": "fgMuted",
|
|
56
54
|
"mdHeading": "blueBright",
|
package/themes/cc-light-hc.json
CHANGED
|
@@ -22,9 +22,7 @@
|
|
|
22
22
|
"purple": "#65339c",
|
|
23
23
|
"pink": "#931662",
|
|
24
24
|
"cyan": "#005359",
|
|
25
|
-
"selection": "#c5d6ee"
|
|
26
|
-
"diffAddBg": "#c3dcd0",
|
|
27
|
-
"diffDelBg": "#f0d5d8"
|
|
25
|
+
"selection": "#c5d6ee"
|
|
28
26
|
},
|
|
29
27
|
"colors": {
|
|
30
28
|
"accent": "blue",
|
|
@@ -32,7 +30,7 @@
|
|
|
32
30
|
"borderAccent": "blue",
|
|
33
31
|
"borderMuted": "borderMuted",
|
|
34
32
|
"success": "green",
|
|
35
|
-
"error": "
|
|
33
|
+
"error": "red",
|
|
36
34
|
"warning": "yellow",
|
|
37
35
|
"muted": "fgMuted",
|
|
38
36
|
"dim": "#4e5966",
|
package/themes/cc-light.json
CHANGED
|
@@ -22,9 +22,7 @@
|
|
|
22
22
|
"purple": "#65339c",
|
|
23
23
|
"pink": "#931662",
|
|
24
24
|
"cyan": "#005359",
|
|
25
|
-
"selection": "#c5d6ee"
|
|
26
|
-
"diffAddBg": "#c3dcd0",
|
|
27
|
-
"diffDelBg": "#f0d5d8"
|
|
25
|
+
"selection": "#c5d6ee"
|
|
28
26
|
},
|
|
29
27
|
"colors": {
|
|
30
28
|
"accent": "blue",
|
|
@@ -49,8 +47,8 @@
|
|
|
49
47
|
"customMessageText": "fgMuted",
|
|
50
48
|
"customMessageLabel": "blue",
|
|
51
49
|
"toolPendingBg": "canvasSubtle",
|
|
52
|
-
"toolSuccessBg": "
|
|
53
|
-
"toolErrorBg": "
|
|
50
|
+
"toolSuccessBg": "#c3dcd0",
|
|
51
|
+
"toolErrorBg": "#f0d5d8",
|
|
54
52
|
"toolTitle": "fg",
|
|
55
53
|
"toolOutput": "fgMuted",
|
|
56
54
|
"mdHeading": "blueBright",
|