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 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 (∑); sums usage.totalTokens from session entries. Off by default
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/缓存图标可关 Nerd Font | `/ccstyle` |
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 芯片(∑);累加会话条目的 usage.totalTokens,默认关
90
+ "footerSessionTokens": false, // 本次对话累计 token 芯片(Nerd Font 图标,纯文本为 Σ);累加会话条目的 usage.totalTokens,默认关
90
91
  "footerCurrency": "USD", // 底栏费用的显示币种
91
92
  "footerCurrencyRate": null, // 1 单位费用换成多少显示币种;null 只换符号、不乘倍率
92
- "footerNerdIcons": true, // git/缓存用 Nerd Font 图标;false 为纯文本
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 cache chips use Nerd Font glyphs. Turn off for plain text."
763
- : "Git and cache chips use plain text. No Nerd Font required.",
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 cache chips use Nerd Font glyphs. Turn off for plain text."
941
- : "Git and cache chips use plain text. No Nerd Font required.";
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
- export function footerGlyphs(nerdIcons: boolean): { git: string; cache: string; mcp: string } {
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
- ? { git: FOOTER_NERD_ICON_GIT, cache: FOOTER_NERD_ICON_CACHE, mcp: FOOTER_NERD_ICON_MCP }
65
- : { git: "", cache: "", mcp: "" };
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 ? formatSessionTokenTotal(tokens) : "";
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
- /** 芯片文案:`∑ 402k`;没有数据返回空串(不占位置)。 */
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 `∑ ${formatTokenCount(Math.round(total))}`;
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
- if (typeof tool?.toolCallId === "string" && ids.has(tool.toolCallId) && isRunningTool(tool)) {
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
- textFromResult(result) || "Pending…",
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 = `${lead}${icon} ${renderToolSummary(summary, mainWidth, theme.fg.bind(theme))}${extraStyled}`;
454
- return [truncateToWidth(cachedLine, viewportWidth, "")];
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("toolDiffAdded", "━".repeat(addedSlots)) : "";
57
- const removedBar = removedSlots > 0 ? theme.fg("toolDiffRemoved", "━".repeat(removedSlots)) : "";
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("toolDiffAdded", `+${stats.added}`),
67
- theme.fg("toolDiffRemoved", `-${stats.removed}`),
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, "toolDiffAdded", glyph, rowBg);
95
+ return colorizeSegment(theme, "success", glyph, rowBg);
96
96
  }
97
97
  if (kind === "remove") {
98
- return colorizeSegment(theme, "toolDiffRemoved", glyph, rowBg);
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" | "toolDiffAdded" | "toolDiffRemoved" {
135
+ function getLineNumberColor(kind: DiffLineKind): "dim" | "success" | "error" {
136
136
  if (kind === "add") {
137
- return "toolDiffAdded";
137
+ return "success";
138
138
  }
139
139
  if (kind === "remove") {
140
- return "toolDiffRemoved";
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" ? "toolDiffAdded" : "toolDiffRemoved";
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" | "toolDiffAdded" | "toolDiffRemoved",
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
- // 删除行用 toolErrorBg 做基底(主题里是 diffDelBg 暗红);不定义时退回通用基底混 tint。
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", "toolDiffAdded")) ?? {
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", "toolDiffRemoved")) ?? {
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("toolDiffAdded", `+${stats.added}`));
99
+ pieces.push(theme.fg("success", `+${stats.added}`));
100
100
  pieces.push(theme.fg("muted", " "));
101
- pieces.push(theme.fg("toolDiffRemoved", `-${stats.removed}`));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "1.0.15",
3
+ "version": "1.0.16",
4
4
  "license": "MIT",
5
5
  "description": "Claude Code-style TUI output with some personal touches and handy utilities.",
6
6
  "type": "module",
@@ -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": "#ffc4bd",
33
+ "error": "red",
36
34
  "warning": "yellow",
37
35
  "muted": "fgMuted",
38
36
  "dim": "#71717a",
@@ -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": "diffAddBg",
53
- "toolErrorBg": "diffDelBg",
50
+ "toolSuccessBg": "#0f2a24",
51
+ "toolErrorBg": "#261418",
54
52
  "toolTitle": "fg",
55
53
  "toolOutput": "fgMuted",
56
54
  "mdHeading": "blueBright",
@@ -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": "#6f0307",
33
+ "error": "red",
36
34
  "warning": "yellow",
37
35
  "muted": "fgMuted",
38
36
  "dim": "#4e5966",
@@ -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": "diffAddBg",
53
- "toolErrorBg": "diffDelBg",
50
+ "toolSuccessBg": "#c3dcd0",
51
+ "toolErrorBg": "#f0d5d8",
54
52
  "toolTitle": "fg",
55
53
  "toolOutput": "fgMuted",
56
54
  "mdHeading": "blueBright",