pi-cc-extensions 1.0.14 → 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,9 +32,10 @@ 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
- | Theme | Bundled CC Dark and CC Light themes | `/settings` → Theme |
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` |
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)
40
41
 
@@ -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,9 +32,10 @@ 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
- | 主题 | 随包提供内置 CC Dark、CC Light 主题 | `/settings` → Theme |
37
+ | 状态栏 | 显示:模型、上下文、缓存、费用、本次对话累计 token、git并适配 @narumitw/pi-usage 实时显示额度;git/缓存/token 图标可关 Nerd Font | `/ccstyle` |
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)
40
41
 
@@ -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();
@@ -49,6 +49,7 @@ import {
49
49
  fullscreenLazyTui,
50
50
  hideScrollButton,
51
51
  dockScrollButtonActive,
52
+ isFullscreenAtBottom,
52
53
  restoreOfficialScrollToEnd,
53
54
  restoreNativeScrollIndicatorText,
54
55
  syncOfficialScrollToEnd,
@@ -457,9 +458,9 @@ function handleFullscreenToolClick(tui: any, packet: SgrMousePacket): boolean {
457
458
  // compact 摘要行自己记展开前的视口(收起时还原跟随),所以它先展开再停跟随:
458
459
  // 顺序反了快照会记下「未跟随」,收起时就交不回官方 follow。
459
460
  component.setExpanded(true);
460
- keepViewportOnExpand(tui);
461
+ keepViewportOnExpand(tui, hit);
461
462
  } else {
462
- keepViewportOnExpand(tui);
463
+ keepViewportOnExpand(tui, hit);
463
464
  component.setExpanded(true);
464
465
  }
465
466
  clearPendingCollapsePress();
@@ -509,13 +510,40 @@ function isSameExpandedRound(other: any, owner: any): boolean {
509
510
  }
510
511
 
511
512
  /**
512
- * 原地展开时停在当前滚动位置:transcript 跟随底部时,内容变高会把视口拖到底,
513
- * 点击的行和它上方的外层面板被顶出屏幕。展开前关掉跟随,点击行留在原位。
513
+ * 点击行在滚动视口里的位置:视口内行号与视口高度。布局树读不到时返回 null。
514
+ * leaf 不带 scrollView,视口是最近的带 scrollView 的祖先 box。
515
+ */
516
+ function clickedRowInViewport(
517
+ hit: { box: any; localRow: number } | null | undefined,
518
+ ): { y: number; height: number } | null {
519
+ if (!hit?.box) return null;
520
+ let scrollBox: any = null;
521
+ for (let current = hit.box; current; current = current.parent) {
522
+ if (current.scrollView) {
523
+ scrollBox = current;
524
+ break;
525
+ }
526
+ }
527
+ const rect = scrollBox?.rect;
528
+ const height = Number(rect?.height);
529
+ const rowY = Number(hit.box.rect?.y);
530
+ if (!rect || !(height > 0) || !Number.isFinite(rowY)) return null;
531
+ return { y: rowY + hit.localRow - Number(rect.y), height };
532
+ }
533
+
534
+ /**
535
+ * 原地展开时视口怎么走。transcript 跟随底部时,内容变高会把视口拖到底,
536
+ * 点击的行和它上方的外层面板被顶出屏幕:展开前关掉跟随,点击行留在原位。
514
537
  * compact 摘要行同样适用:它的展开入口在 setExpanded 之后调用本函数(见调用点)。
538
+ *
539
+ * 例外是点击行已经落在视口下半屏:它下面没有足够空间,冻结只会把展开出来的
540
+ * 内容全推到屏幕外(贴着底部输入框时最常见)。这种位置保持跟随,让新内容顶上来。
515
541
  */
516
- function keepViewportOnExpand(tui: any): void {
542
+ function keepViewportOnExpand(tui: any, hit?: { box: any; localRow: number } | null): void {
517
543
  const view = tui.getPrimaryScrollView?.();
518
544
  if (typeof view?.scrollTo !== "function" || typeof view.scrollTop !== "number") return;
545
+ const spot = clickedRowInViewport(hit);
546
+ if (spot && isFullscreenAtBottom(tui) && spot.y >= spot.height / 2) return;
519
547
  view.scrollTo(view.scrollTop, { disableFollow: true });
520
548
  // 已脱离底部跟随:dock 模式亮出回到底部按钮,新输出到来时仍有入口。
521
549
  if (view.isFollowingEnd === false && dockScrollButtonActive()) setScrollButtonVisible(true);
@@ -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"
@@ -194,6 +195,69 @@ function shellDurationText(toolName: string, context: any, isPartial: boolean):
194
195
  return elapsed === undefined ? "" : formatToolDuration(elapsed);
195
196
  }
196
197
 
198
+ /**
199
+ * 运行中的展开视图:与结束后同一份 Input / Output。
200
+ * 还没输出时用 Pending… 占位:空正文会被视图画成 Done,像已经跑完了。
201
+ * 纯空白(空格、换行、tab)同样算还没输出;非空正文保留原始缩进,不整体 trim。
202
+ * shell 的已用时间挂到视图尾部;取值函数每帧重算,秒数才跟着动画 tick 走。
203
+ */
204
+ function renderRunningExpandedResult(toolName: string, result: any, context: any, theme: any): any {
205
+ const text = textFromResult(result);
206
+ const view = renderExpandedToolResult(
207
+ text.trim() ? text : "Pending…",
208
+ theme,
209
+ false,
210
+ context?.lastComponent,
211
+ context?.args,
212
+ context,
213
+ true,
214
+ );
215
+ if (isExpandedToolIoView(view) && SHELL_TOOL_NAMES.has(toolName)) {
216
+ view.setFooter(() => shellDurationText(toolName, context, true) || undefined);
217
+ }
218
+ return view;
219
+ }
220
+
221
+ /**
222
+ * 运行中的折叠行:` ↳ Pending… · 12.4s · click to show more`。
223
+ * 提示必须留在行尾:collapsedHintHitbox 只认行尾,没有它这行既点不动也看不出能点。
224
+ * 已用时间在 render() 内取,动画 tick 才能带上新值。
225
+ */
226
+ function renderRunningCollapsedResult(options: {
227
+ toolName: string;
228
+ context: any;
229
+ theme: any;
230
+ /** ask_user_question 的结果树用 1 格缩进,与它的 ├/└ 对齐。 */
231
+ prefix?: string;
232
+ }): any {
233
+ const { toolName, context, theme, prefix } = options;
234
+ const toolCallId = context?.toolCallId;
235
+ const hintPrefix = theme.fg("dim", " · ");
236
+ const hint = hintPrefix + theme.fg("dim", showMoreHintText());
237
+ const hoveredHint = hintPrefix + theme.fg("text", showMoreHintText());
238
+ let cachedWidth: number | undefined;
239
+ let cachedBody: string | undefined;
240
+ let cachedLine: string | undefined;
241
+ let cachedHoveredLine: string | undefined;
242
+ return {
243
+ render(width: number) {
244
+ const elapsed = shellDurationText(toolName, context, true);
245
+ const body = elapsed ? `Pending… · ${elapsed}` : "Pending…";
246
+ if (cachedLine === undefined || cachedWidth !== width || cachedBody !== body) {
247
+ cachedWidth = width;
248
+ cachedBody = body;
249
+ cachedLine = theme.fg("muted", renderCollapsedToolResultToWidth(body, hint, width, prefix));
250
+ cachedHoveredLine = theme.fg(
251
+ "muted",
252
+ renderCollapsedToolResultToWidth(body, hoveredHint, width, prefix),
253
+ );
254
+ }
255
+ return [isToolCallHovered(toolCallId) ? cachedHoveredLine! : cachedLine];
256
+ },
257
+ invalidate(): void {},
258
+ };
259
+ }
260
+
197
261
  /**
198
262
  * Agent 的 renderResult:与 pi-subagents 保持同样的分支,但收敛成 ccstyle 的 ↳ 行。
199
263
  * running/partial 转交原渲染器(只有它有 spinner frame 和 activity 文案);
@@ -389,8 +453,12 @@ export function createCcstyleTool(
389
453
  cachedWidth = width;
390
454
  cachedIcon = icon;
391
455
  // 路径按最终可用宽度中间截断,避免整行二次截断隐藏文件名。
392
- cachedLine = `${lead}${icon} ${renderToolSummary(summary, mainWidth, theme.fg.bind(theme))}${extraStyled}`;
393
- 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];
394
462
  },
395
463
  invalidate() {
396
464
  cachedLine = undefined;
@@ -454,9 +522,11 @@ export function createCcstyleTool(
454
522
  if (toolName === ASK_USER_QUESTION_TOOL_NAME) {
455
523
  if (!options?.isPartial) setToolVisualState(context, isError ? "error" : "success");
456
524
  if (options?.isPartial) {
457
- // 折叠态 1 格缩进:与完成后的 ├/└ 对齐标题行的勾。
458
- const pending = expanded ? "↳ Pending…" : " ↳ Pending…";
459
- return new Text(theme.fg("muted", pending), 0, 0);
525
+ // 等待回答时也能展开看问题原文(Input 即 args.questions);折叠态 1 格缩进。
526
+ if (expanded) return renderRunningExpandedResult(toolName, result, context, theme);
527
+ // 折叠后清掉展开视图,与通用 partial 折叠一致。
528
+ if (context?.state) context.state.ccstyleIoView = undefined;
529
+ return renderRunningCollapsedResult({ toolName, context, theme, prefix: " ↳ " });
460
530
  }
461
531
  if (expanded) {
462
532
  return renderExpandedToolResult(
@@ -479,21 +549,11 @@ export function createCcstyleTool(
479
549
  });
480
550
  }
481
551
  if (options?.isPartial) {
552
+ // 运行中也给和结束后同一份 Input / Output:命令全文与已产生的输出。
553
+ if (expanded) return renderRunningExpandedResult(toolName, result, context, theme);
482
554
  // 折叠态保持 3 格:与完成后的 ` ↳ …` 同列,工具跑完那行不横跳。
483
- // 展开态贴左(Box 提供 1 格 pad)。
484
- const pending = expanded ? "↳ Pending…" : " ↳ Pending…";
485
- if (!SHELL_TOOL_NAMES.has(toolName)) {
486
- return new Text(theme.fg("muted", pending), 0, 0);
487
- }
488
- // shell 运行中报已用时间:数字在 render() 内取,动画 tick 才能带上新值。
489
- return {
490
- render(width: number) {
491
- const elapsed = shellDurationText(toolName, context, true);
492
- const suffix = elapsed ? theme.fg("dim", ` · ${elapsed}`) : "";
493
- return [truncateToWidth(theme.fg("muted", pending) + suffix, width, "")];
494
- },
495
- invalidate(): void {},
496
- };
555
+ if (context?.state) context.state.ccstyleIoView = undefined;
556
+ return renderRunningCollapsedResult({ toolName, context, theme });
497
557
  }
498
558
 
499
559
  // shell:结果定型时定一次结束时间,这一行之后不再变。
@@ -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) {
@@ -26,6 +26,7 @@ import { mcpToolTitle } from "./mcp-title.ts";
26
26
  import { collapseHintText, isToolTuiFullscreen, showMoreHintText } from "./names.ts";
27
27
  import {
28
28
  contentRowRange,
29
+ hasVisibleText,
29
30
  imagePlacement,
30
31
  stripAnsi,
31
32
  stripBackgroundAnsi,
@@ -364,10 +365,17 @@ function expandedToolRows(tool: any, width: number): string[] {
364
365
  const cached = expandedToolRowsCache.get(paint);
365
366
  if (cached) return cached;
366
367
  const rendered = visibleLines(paint);
367
- if (rendered.length) {
368
- rendered[0] = stripLeadingStatusIcon(rendered[0])
368
+ // 首行的状态图标由组前缀给,子行里要剥掉。
369
+ const stripIcon = (line: string) =>
370
+ stripLeadingStatusIcon(line)
369
371
  .replace(/^ +/, "")
370
372
  .replace(/^((?:\x1b\[[0-?]*[ -/]*[@-~])*) +/, "$1");
373
+ // 剥完什么都没剩下(子卡首行只剩一个图标)就丢掉这一行,再对新的首行剥一次:
374
+ // 留着会多出一行只有 `├ ✓` 的空行,标题行也会带着图标和前缀叠成两个。
375
+ while (rendered.length) {
376
+ rendered[0] = stripIcon(rendered[0]);
377
+ if (hasVisibleText(rendered[0])) break;
378
+ rendered.shift();
371
379
  }
372
380
  const rows = rendered.length
373
381
  ? // 续行只剥外层 Box 的 1 格 left pad,保留 Input/Output 相对缩进