pi-cc-extensions 1.0.7 → 1.0.9
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 +10 -9
- package/README.md +6 -5
- package/extensions/config/config.ts +1 -1
- package/extensions/config/panel.ts +14 -5
- package/extensions/feature/shell/footer.ts +28 -6
- package/extensions/feature/shell/prompt-editor.ts +33 -12
- package/extensions/renderer/compact-mode.ts +142 -45
- package/extensions/renderer/mouse/interaction.ts +24 -6
- package/extensions/renderer/mouse/scroll.ts +69 -16
- package/extensions/renderer/mouse/sticky-prompt.ts +1 -1
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
# pi-cc-extensions
|
|
6
6
|
|
|
7
|
-
> Claude Code-style TUI output with some personal touches and handy utilities.
|
|
7
|
+
> Claude Code-style TUI output with some personal touches and handy utilities (no extra built-in tools).
|
|
8
8
|
|
|
9
9
|
## Preview
|
|
10
10
|
|
|
@@ -34,7 +34,7 @@ Run `/reload` after installation.
|
|
|
34
34
|
| Context inspection | Usage breakdown and previews for the system prompt, memory, skills, tools definition, and messages | `/context` |
|
|
35
35
|
| Session/Subagent references | Search and inject effective context from previous Sessions or existing SubAgents | `@` |
|
|
36
36
|
| Status bar | Shows model, context, cache, cost, 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 | `/
|
|
37
|
+
| Theme | Bundled CC Dark and CC Light themes | `/settings` → Theme |
|
|
38
38
|
|
|
39
39
|
Renderer snapshots for `on` / `compact` modes: [default](./docs/tool-render-examples-default.md) · [compact](./docs/tool-render-examples-compact.md)
|
|
40
40
|
|
|
@@ -55,7 +55,7 @@ Renderer snapshots for `on` / `compact` modes: [default](./docs/tool-render-exam
|
|
|
55
55
|
"enableAgentSummary": true, // per-turn tool summary
|
|
56
56
|
"enableWorkingMessage": true, // Working... bottom token/elapsed
|
|
57
57
|
"enableAliases": true, // /clear, /exit aliases
|
|
58
|
-
"enableMarkdownEnhance": true,
|
|
58
|
+
"enableMarkdownEnhance": true, // markdown pass: Mermaid dialects and circled-number conversion; false hands it to other extensions
|
|
59
59
|
"enableEditorPrefix": true, // editor ❯ prefix
|
|
60
60
|
|
|
61
61
|
// ui
|
|
@@ -97,10 +97,10 @@ Renderer snapshots for `on` / `compact` modes: [default](./docs/tool-render-exam
|
|
|
97
97
|
```
|
|
98
98
|
|
|
99
99
|
> [!TIP]
|
|
100
|
-
> **Fullscreen**:
|
|
100
|
+
> **Fullscreen**: expanded diffs always show every line. When expanded Input/Output exceeds the line cap, the footer shows `… +N more lines`; click for the full preview, click again to collapse.
|
|
101
101
|
|
|
102
102
|
> [!NOTE]
|
|
103
|
-
> **Mermaid rendering**: set `markdown.mermaid` to `final` via `~/.pi/agent/settings.json` or
|
|
103
|
+
> **Mermaid rendering**: set `markdown.mermaid` to `final` via `~/.pi/agent/settings.json` or `/settings`. Default `streaming` redraws every frame; `final` renders once.
|
|
104
104
|
|
|
105
105
|
## Local development
|
|
106
106
|
|
|
@@ -116,10 +116,11 @@ pi -e . # one run, does not edit settings.json
|
|
|
116
116
|
|
|
117
117
|
## Recommended companions
|
|
118
118
|
|
|
119
|
-
| Extension
|
|
120
|
-
|
|
|
121
|
-
| `npm:pi-web-access`
|
|
122
|
-
| `npm:@narumitw/pi-usage`
|
|
119
|
+
| Extension | Purpose |
|
|
120
|
+
| ---------------------------------------- | ----------------------------------------------------------- |
|
|
121
|
+
| `npm:pi-web-access` | Web search, URL fetching, GitHub cloning, PDF/video parsing |
|
|
122
|
+
| `npm:@narumitw/pi-usage` | Current-account usage for Codex / Copilot / OpenRouter |
|
|
123
|
+
| `npm:@juicesharp/rpiv-ask-user-question` | Multiple-choice questionnaire the model can ask you |
|
|
123
124
|
|
|
124
125
|
## Credits
|
|
125
126
|
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
# pi-cc-extensions
|
|
6
6
|
|
|
7
|
-
> 类 Claude Code TUI
|
|
7
|
+
> 类 Claude Code TUI 输出风格,并融入了一些个人喜好,和一些实用小功能(无额外内置工具)。
|
|
8
8
|
|
|
9
9
|
## 界面预览
|
|
10
10
|
|
|
@@ -34,7 +34,7 @@ pi install git:github.com/minuque/pi-cc-extensions
|
|
|
34
34
|
| 上下文检查 | 查看上下文占用,并预览 System prompt、Memory、Skills、Tools definition 和消息内容 | `/context` |
|
|
35
35
|
| Session/Subagent 引用 | 搜索并注入历史 Session 或现有 SubAgent 的有效上下文 | `@` |
|
|
36
36
|
| 状态栏 | 显示:模型、上下文、缓存、费用、git并适配 @narumitw/pi-usage 实时显示额度;git/缓存图标可关 Nerd Font | `/ccstyle` |
|
|
37
|
-
| 主题 | 随包提供内置 CC Dark、CC Light 主题 | `/
|
|
37
|
+
| 主题 | 随包提供内置 CC Dark、CC Light 主题 | `/settings` → Theme |
|
|
38
38
|
|
|
39
39
|
`on` / `compact` 两种模式的渲染样例:[默认模式](./docs/tool-render-examples-default.md) · [紧凑模式](./docs/tool-render-examples-compact.md)
|
|
40
40
|
|
|
@@ -55,7 +55,7 @@ pi install git:github.com/minuque/pi-cc-extensions
|
|
|
55
55
|
"enableAgentSummary": true, // 每回合工具摘要
|
|
56
56
|
"enableWorkingMessage": true, // Working... 底部 token/耗时
|
|
57
57
|
"enableAliases": true, // /clear、/exit 别名
|
|
58
|
-
"enableMarkdownEnhance": true,
|
|
58
|
+
"enableMarkdownEnhance": true, // markdown 转换:Mermaid 方言渲染、圈数字转半角括号;false 让位给其他扩展
|
|
59
59
|
"enableEditorPrefix": true, // 输入框 ❯ 前缀
|
|
60
60
|
|
|
61
61
|
// ui
|
|
@@ -97,10 +97,10 @@ pi install git:github.com/minuque/pi-cc-extensions
|
|
|
97
97
|
```
|
|
98
98
|
|
|
99
99
|
> [!TIP]
|
|
100
|
-
>
|
|
100
|
+
> **全屏模式**:展开态 diff 全量显示。Input/Output 超行时末行给 `… +N more lines`,单击看全量,再击收起。
|
|
101
101
|
|
|
102
102
|
> [!NOTE]
|
|
103
|
-
> **Mermaid
|
|
103
|
+
> **Mermaid 渲染**:`markdown.mermaid` 建议设为 `final`(`~/.pi/agent/settings.json` 或 `/settings`)。默认 `streaming` 逐帧重绘,`final` 只渲染最终版。
|
|
104
104
|
|
|
105
105
|
## 本地开发
|
|
106
106
|
|
|
@@ -120,6 +120,7 @@ pi -e . # 只跑一次,不改 settings.json
|
|
|
120
120
|
| ---------------------------------------- | ------------------------------------------------ |
|
|
121
121
|
| `npm:pi-web-access` | 网页搜索、URL 抓取、GitHub 克隆、PDF/视频解析 |
|
|
122
122
|
| `npm:@narumitw/pi-usage` | 查看当前账号用量(Codex / Copilot / OpenRouter) |
|
|
123
|
+
| `npm:@juicesharp/rpiv-ask-user-question` | 模型拿不准时向你提问的选择题面板 |
|
|
123
124
|
|
|
124
125
|
## 致谢
|
|
125
126
|
|
|
@@ -66,7 +66,7 @@ export type Config = {
|
|
|
66
66
|
showStartupHeader: boolean;
|
|
67
67
|
/** fullscreen:userMessage 正文首行滚出视口上沿后钉住,直到下一条指令接手。 */
|
|
68
68
|
stickyUserMessage: boolean;
|
|
69
|
-
/** fullscreen 回到底部按钮:扩展 dock 行,或 Pi
|
|
69
|
+
/** fullscreen 回到底部按钮:扩展 dock 行,或 Pi 原生浮层;两者文案相同。 */
|
|
70
70
|
scrollButtonMode: ScrollButtonMode;
|
|
71
71
|
enableSessionReference: boolean;
|
|
72
72
|
enableSubagentAutocomplete: boolean;
|
|
@@ -431,6 +431,11 @@ export async function showCcstylePanel(
|
|
|
431
431
|
return;
|
|
432
432
|
}
|
|
433
433
|
|
|
434
|
+
// 编辑器插槽只能在面板关闭后替换:面板自己就挂在 editorContainer 上,面板开着时调
|
|
435
|
+
// setEditorComponent 会把面板踢出渲染树、done() 再也走不到,`ctx.ui.custom()` 永不落定,
|
|
436
|
+
// 整个输入管线随之卡死。
|
|
437
|
+
let editorPrefixChanged = false;
|
|
438
|
+
|
|
434
439
|
await ctx.ui.custom((tui: any, theme: any, _keybindings: any, done: () => void) => {
|
|
435
440
|
const modeSetting = {
|
|
436
441
|
id: "mode",
|
|
@@ -604,11 +609,11 @@ export async function showCcstylePanel(
|
|
|
604
609
|
};
|
|
605
610
|
const scrollButtonSetting = {
|
|
606
611
|
id: "scrollButtonMode",
|
|
607
|
-
label: "Scroll
|
|
612
|
+
label: "Scroll button mode",
|
|
608
613
|
description:
|
|
609
614
|
config.scrollButtonMode === "dock"
|
|
610
615
|
? "Fullscreen: show the extension's dedicated Back to bottom row."
|
|
611
|
-
: "Fullscreen:
|
|
616
|
+
: "Fullscreen: show the same Back to bottom text in Pi's native floating overlay.",
|
|
612
617
|
currentValue: config.scrollButtonMode,
|
|
613
618
|
values: [...SCROLL_BUTTON_MODES],
|
|
614
619
|
};
|
|
@@ -880,8 +885,7 @@ export async function showCcstylePanel(
|
|
|
880
885
|
editorPrefixSetting.description = enabled
|
|
881
886
|
? "Show ❯ before the input line; color follows thinking level / bash mode."
|
|
882
887
|
: "Plain input line, no ❯ glyph.";
|
|
883
|
-
|
|
884
|
-
else clearPromptEditor(ctx);
|
|
888
|
+
editorPrefixChanged = true;
|
|
885
889
|
ctx.ui.notify(`Updated ${id}: ${value}`, "info");
|
|
886
890
|
return;
|
|
887
891
|
}
|
|
@@ -1020,7 +1024,7 @@ export async function showCcstylePanel(
|
|
|
1020
1024
|
scrollButtonSetting.description =
|
|
1021
1025
|
config.scrollButtonMode === "dock"
|
|
1022
1026
|
? "Fullscreen: show the extension's dedicated Back to bottom row."
|
|
1023
|
-
: "Fullscreen:
|
|
1027
|
+
: "Fullscreen: show the same Back to bottom text in Pi's native floating overlay.";
|
|
1024
1028
|
break;
|
|
1025
1029
|
}
|
|
1026
1030
|
case "showStartupHeader": {
|
|
@@ -1208,6 +1212,11 @@ export async function showCcstylePanel(
|
|
|
1208
1212
|
},
|
|
1209
1213
|
};
|
|
1210
1214
|
});
|
|
1215
|
+
// 面板已经卸下,插槽此时替换才不会踢掉对话框。
|
|
1216
|
+
if (editorPrefixChanged) {
|
|
1217
|
+
if (config.enableEditorPrefix) applyPromptEditor(ctx);
|
|
1218
|
+
else clearPromptEditor(ctx);
|
|
1219
|
+
}
|
|
1211
1220
|
// 面板卸下后主 transcript 重新挂载;再刷一次,吃掉打开期间扫树失败的切换。
|
|
1212
1221
|
hooks.refreshCurrentTranscript(ctx, toolGrouping);
|
|
1213
1222
|
}
|
|
@@ -385,12 +385,24 @@ const createCustomFooterFactory =
|
|
|
385
385
|
return 0;
|
|
386
386
|
};
|
|
387
387
|
|
|
388
|
-
|
|
388
|
+
// 三个会话统计每帧重算会全量遍历 session,长会话把 pi 钉在 100% CPU(issue #80)。
|
|
389
|
+
// leaf/model/thinking 任一变化立即重算;不变时 1s TTL,流式期间每秒刷新一次。
|
|
390
|
+
const SESSION_STATS_TTL_MS = 1_000;
|
|
391
|
+
let statsMemoKey = "";
|
|
392
|
+
let statsMemoAt = 0;
|
|
393
|
+
let statsMemo:
|
|
394
|
+
| { contextUsage: ReturnType<typeof ctx.getContextUsage>; cost: number; cachePct: number }
|
|
395
|
+
| undefined;
|
|
396
|
+
const sessionStats = () => {
|
|
389
397
|
const model = ctx.model;
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
398
|
+
const key = `${ctx.sessionManager.getLeafId() ?? ""}|${model?.provider}/${model?.id}|${ctx.thinkingLevel ?? ""}`;
|
|
399
|
+
if (
|
|
400
|
+
statsMemo &&
|
|
401
|
+
key === statsMemoKey &&
|
|
402
|
+
performance.now() - statsMemoAt < SESSION_STATS_TTL_MS
|
|
403
|
+
) {
|
|
404
|
+
return statsMemo;
|
|
405
|
+
}
|
|
394
406
|
let cost = 0;
|
|
395
407
|
for (const entry of ctx.sessionManager.getEntries()) {
|
|
396
408
|
if (entry.type === "message") {
|
|
@@ -404,6 +416,17 @@ const createCustomFooterFactory =
|
|
|
404
416
|
cost += entry.usage.cost?.total || 0;
|
|
405
417
|
}
|
|
406
418
|
}
|
|
419
|
+
statsMemo = { contextUsage: ctx.getContextUsage(), cost, cachePct: getCachePct() };
|
|
420
|
+
statsMemoKey = key;
|
|
421
|
+
statsMemoAt = performance.now();
|
|
422
|
+
return statsMemo;
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
const render = (width: number): string[] => {
|
|
426
|
+
const model = ctx.model;
|
|
427
|
+
const { contextUsage, cost, cachePct } = sessionStats();
|
|
428
|
+
const contextWindow = contextUsage?.contextWindow ?? model?.contextWindow ?? 0;
|
|
429
|
+
const percent = contextUsage?.percent;
|
|
407
430
|
|
|
408
431
|
// cwd 默认只显示最底层路径(根目录时回退完整路径)
|
|
409
432
|
const cwdLabel = ctx.cwd.split(/[\\/]/).filter(Boolean).pop() || ctx.cwd;
|
|
@@ -430,7 +453,6 @@ const createCustomFooterFactory =
|
|
|
430
453
|
const dimPlugin = (text: string) => colorUsageChip(theme, text);
|
|
431
454
|
|
|
432
455
|
const pctLabel = percent === null || percent === undefined ? "?" : `${Math.floor(percent)}%`;
|
|
433
|
-
const cachePct = getCachePct();
|
|
434
456
|
const glyphs = footerGlyphs(config.footerNerdIcons);
|
|
435
457
|
const cacheLabel =
|
|
436
458
|
cachePct > 0 ? `${glyphs.cache ? `${glyphs.cache} ` : ""}${Math.floor(cachePct)}%` : "";
|
|
@@ -59,27 +59,48 @@ const promptEditorFactory: EditorFactory = (tui, theme, keybindings) =>
|
|
|
59
59
|
let active = false;
|
|
60
60
|
let previousEditorFactory: EditorFactory | undefined;
|
|
61
61
|
|
|
62
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* 撤销本扩展占据的插槽;从未占用就不碰插槽。
|
|
64
|
+
*
|
|
65
|
+
* 插槽可能已经被其他扩展包在外层(pi-inline-skills 会把当前工厂再包一层),
|
|
66
|
+
* 那种结构里无法只拆内层,直接还回占据前的工厂;外层扩展会在下次终端输入时
|
|
67
|
+
* 自己重新包装,前缀不会因此复活。
|
|
68
|
+
*
|
|
69
|
+
* 例外:插槽被 pi 重置回占据前的工厂时(/new、/reload 走 resetExtensionUI),
|
|
70
|
+
* 我们已经不在槽里,再写一次会把 pi 刚重置的结果又覆盖掉;此时只清状态。
|
|
71
|
+
*/
|
|
63
72
|
function releasePromptEditor(ctx: ExtensionContext): void {
|
|
64
73
|
if (!active) return;
|
|
65
|
-
|
|
74
|
+
const previous = previousEditorFactory;
|
|
66
75
|
active = false;
|
|
67
76
|
previousEditorFactory = undefined;
|
|
77
|
+
if (ctx.ui.getEditorComponent?.() === previous) return;
|
|
78
|
+
ctx.ui.setEditorComponent(previous);
|
|
68
79
|
}
|
|
69
80
|
|
|
70
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* 应用输入框前缀;enableEditorPrefix=false 时只撤自己装的那层。
|
|
83
|
+
*
|
|
84
|
+
* 只在没有扩展对话框开着时调用:pi 的 setEditorComponent 会清空 editorContainer 并
|
|
85
|
+
* 抢走焦点,把正在设置面板里的对话框踢出渲染树,对话框的 done() 再也走不到,
|
|
86
|
+
* `ctx.ui.custom()` 的 Promise 永不落定,后续输入全部卡死。设置面板自己不做替换,
|
|
87
|
+
* 等 `ctx.ui.custom()` 返回后再调本函数。
|
|
88
|
+
*
|
|
89
|
+
* 插槽已经装着本扩展的编辑器时直接返回:重复安装只会重建实例,丢掉撤销栈、
|
|
90
|
+
* 历史等编辑器状态,还会打断其他扩展包在外层的编辑器。
|
|
91
|
+
*/
|
|
71
92
|
export function applyPromptEditor(ctx: ExtensionContext): void {
|
|
72
93
|
if (!ctx?.hasUI || typeof ctx.ui?.setEditorComponent !== "function") return;
|
|
94
|
+
if (!config.enableEditorPrefix) {
|
|
95
|
+
releasePromptEditor(ctx);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const current = ctx.ui.getEditorComponent?.();
|
|
99
|
+
if (current === promptEditorFactory) return;
|
|
73
100
|
try {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
if (!active) {
|
|
79
|
-
// pi 的插槽是硬替换、没有栈,先记住是谁占着,撤销时还回去。
|
|
80
|
-
previousEditorFactory = ctx.ui.getEditorComponent?.();
|
|
81
|
-
active = true;
|
|
82
|
-
}
|
|
101
|
+
// pi 的插槽是硬替换、没有栈,先记住是谁占着,撤销时还回去。
|
|
102
|
+
previousEditorFactory = current;
|
|
103
|
+
active = true;
|
|
83
104
|
ctx.ui.setEditorComponent(promptEditorFactory);
|
|
84
105
|
} catch (err) {
|
|
85
106
|
ctx.ui.notify(
|
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
* 与 `editDiffCollapsedLines` / `writeDiffCollapsedLines` / `expandedPreviewMaxLines`。
|
|
8
8
|
* Agent/Task 族:调用只进摘要计数,tool 卡始终折叠(避免 pending→完成高度闪动)。
|
|
9
9
|
* edit/write 与 ask_user_question 留在外层:不进摘要计数,折叠态也单独成行。
|
|
10
|
+
* 外层行一旦在消息里出现,当前回合就地完结(不计静默期),后续消息另起回合:
|
|
11
|
+
* 摘要行因此只折叠连续的一段工具调用,展开/收起不再跨外层行搬位置。
|
|
10
12
|
* 用户消息是回合的硬边界:steer 消息插进回合中间、或上一回合没正文就收尾时,回合
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
+
* 就地收口、后续消息另起一回合。custom 通知/summary 类组件既不是成员也不算外来行,
|
|
14
|
+
* 原地留在两段之间,不打断统计也不被挪位。
|
|
13
15
|
* 底部 Agents/Tasks 面板由 pi-subagents/pi-tasks 独立 widget 负责,不经 tool 卡外置。
|
|
14
16
|
*
|
|
15
17
|
* live 围观态(回合进行中 / 收尾静默期):正文跟折叠态同形;摘要行与单一槽位卡
|
|
@@ -30,6 +32,7 @@
|
|
|
30
32
|
*/
|
|
31
33
|
import {
|
|
32
34
|
AssistantMessageComponent,
|
|
35
|
+
CustomMessageComponent,
|
|
33
36
|
SkillInvocationMessageComponent,
|
|
34
37
|
ToolExecutionComponent,
|
|
35
38
|
UserMessageComponent,
|
|
@@ -385,6 +388,9 @@ function layoutExpandedToolCard(
|
|
|
385
388
|
): { lines: string[]; hits: Array<{ child: any; start: number; end: number }> } {
|
|
386
389
|
const slot = aggregateToolCardBgSlot(children.map((child) => child?.__ccTool).filter(Boolean));
|
|
387
390
|
const toolBgAnsi = darkenBgAnsi(theme, slot);
|
|
391
|
+
// 展开面板外卡底色随组内工具状态聚合(与 default 模式分组卡同口径):
|
|
392
|
+
// 有一个失败用失败底色、没有失败但有进行中用进行中底色、全成功用成功底色。
|
|
393
|
+
const outerSlot = slot;
|
|
388
394
|
/** live 每行前面的标记宽度(首行 `↳ `,其余对齐空格)。 */
|
|
389
395
|
const markerWidth = live ? 2 : 0;
|
|
390
396
|
const innerWidth = Math.max(0, width - 2 - markerWidth);
|
|
@@ -399,7 +405,7 @@ function layoutExpandedToolCard(
|
|
|
399
405
|
// 面板 pad 1 是唯一左边距:剥掉子行 collapsed 态自带的 1 格 lead,
|
|
400
406
|
// 否则标题行偏到 2、↳ 行偏到 4,左右不再对称。
|
|
401
407
|
const line = raw.replace(/^((?:\x1b\[[0-?]*[ -/]*[@-~])*) /, "$1");
|
|
402
|
-
lines.push(paddedBackgroundRow(theme,
|
|
408
|
+
lines.push(paddedBackgroundRow(theme, outerSlot, line, width));
|
|
403
409
|
return;
|
|
404
410
|
}
|
|
405
411
|
// 槽位里不重复展开入口(见 SLOT_HINT_PATTERNS)。
|
|
@@ -420,14 +426,13 @@ function layoutExpandedToolCard(
|
|
|
420
426
|
};
|
|
421
427
|
const pushBlank = () => {
|
|
422
428
|
if (live || lastBlank) return;
|
|
423
|
-
lines.push(paddedBackgroundRow(theme,
|
|
429
|
+
lines.push(paddedBackgroundRow(theme, outerSlot, "", width));
|
|
424
430
|
lastBlank = true;
|
|
425
431
|
};
|
|
426
432
|
for (let childIndex = 0; childIndex < children.length; childIndex++) {
|
|
427
433
|
const child = children[childIndex];
|
|
428
434
|
const painted = paints?.[childIndex];
|
|
429
435
|
const childLines = Array.isArray(painted) ? painted : child.render(innerWidth);
|
|
430
|
-
// live 不留空行、也不叠深色内卡:槽位没有边框。
|
|
431
436
|
const innerCard = !live && isThinkingPreview(child) && child.expanded === true;
|
|
432
437
|
let first = -1;
|
|
433
438
|
let last = -1;
|
|
@@ -440,7 +445,7 @@ function layoutExpandedToolCard(
|
|
|
440
445
|
if (first < 0) {
|
|
441
446
|
if (live) continue;
|
|
442
447
|
for (const line of childLines) {
|
|
443
|
-
lines.push(paddedBackgroundRow(theme,
|
|
448
|
+
lines.push(paddedBackgroundRow(theme, outerSlot, line, width));
|
|
444
449
|
lastBlank = !hasVisibleText(line);
|
|
445
450
|
}
|
|
446
451
|
continue;
|
|
@@ -502,6 +507,7 @@ function compactRoundCard(
|
|
|
502
507
|
| undefined;
|
|
503
508
|
const layout = (width: number) => {
|
|
504
509
|
const theme = themeOf();
|
|
510
|
+
// 与 default 模式分组卡同口径:外卡底色随组内工具聚合,子卡状态变了要重绘。
|
|
505
511
|
const bgSlot = aggregateToolCardBgSlot(
|
|
506
512
|
children.map((child) => child?.__ccTool).filter(Boolean),
|
|
507
513
|
);
|
|
@@ -1051,6 +1057,14 @@ function compactStopStatusLine(status: string, pad = 0): any {
|
|
|
1051
1057
|
/** 展开入口的动作词:我们的 `click to show more`、pi 的 `<key> to expand`(键名可能取不到)。 */
|
|
1052
1058
|
const EXPAND_ACTION = String.raw`(?:click to show more|(?:[\w/+]+\s+)?to (?:show more|expand))`;
|
|
1053
1059
|
|
|
1060
|
+
/**
|
|
1061
|
+
* 展开入口只挂在行尾:正则只看行尾这一段,单行代价与行长无关。
|
|
1062
|
+
* 图片负载(kitty 图形数据的 Base64)一行可达数百 KB,整行过正则会拖住渲染帧。
|
|
1063
|
+
*/
|
|
1064
|
+
const SLOT_HINT_WINDOW = 200;
|
|
1065
|
+
/** 窗口里出现这些词才可能有展开入口,其余行(负载、代码)直接放过正则。 */
|
|
1066
|
+
const SLOT_HINT_MARKERS = ["to expand", "to show more"];
|
|
1067
|
+
|
|
1054
1068
|
const SLOT_HINT_PATTERNS: RegExp[] = [
|
|
1055
1069
|
// 卡在括号里:`(9 more lines, click to show more)` / `(195 earlier lines, ctrl+o to expand)`,
|
|
1056
1070
|
// 只去掉动作词,收尾括号与计数保留。
|
|
@@ -1060,13 +1074,18 @@ const SLOT_HINT_PATTERNS: RegExp[] = [
|
|
|
1060
1074
|
new RegExp(String.raw`\s*(?:[•·]\s*)?${EXPAND_ACTION}\s*$`),
|
|
1061
1075
|
];
|
|
1062
1076
|
|
|
1063
|
-
|
|
1064
|
-
|
|
1077
|
+
/** 导出供单测:窗口边界与键名长度是性能与裁剪行为的关键,需要逐条钉住。 */
|
|
1078
|
+
export function stripSlotHint(line: string): string {
|
|
1079
|
+
// 切点可能落在转义序列中间,残片只当作窗口头部文本;两个模式都锚在行尾,不受影响。
|
|
1080
|
+
const head = line.slice(0, Math.max(0, line.length - SLOT_HINT_WINDOW));
|
|
1081
|
+
const tail = line.slice(head.length);
|
|
1082
|
+
const plain = stripTerminalSequencesPreservingLayout(tail);
|
|
1083
|
+
if (!SLOT_HINT_MARKERS.some((marker) => plain.includes(marker))) return line;
|
|
1065
1084
|
for (const re of SLOT_HINT_PATTERNS) {
|
|
1066
1085
|
const match = re.exec(plain);
|
|
1067
1086
|
if (!match) continue;
|
|
1068
1087
|
// 删除区间而非截断后拼纯文本:收尾括号留在原样式里(dim),不会掉回默认前景色。
|
|
1069
|
-
return removeStyledRange(
|
|
1088
|
+
return head + removeStyledRange(tail, match.index, match.index + match[0].length);
|
|
1070
1089
|
}
|
|
1071
1090
|
return line;
|
|
1072
1091
|
}
|
|
@@ -1320,6 +1339,16 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1320
1339
|
}, FOLD_SETTLE_MS);
|
|
1321
1340
|
};
|
|
1322
1341
|
|
|
1342
|
+
/**
|
|
1343
|
+
* 外层行(edit/write/ask)切出的段落封口:跳过静默期直接定稿。
|
|
1344
|
+
* 静默期是为"正文 token 先于 toolCall 到达"的双重转场准备的;外层边界
|
|
1345
|
+
* 出现时摘要段内容已完整,立即收拢让 Ran for 当场落位,槽位卡一并撤走。
|
|
1346
|
+
*/
|
|
1347
|
+
const sealRound = (round: CompactRound, endTimestamp?: number): void => {
|
|
1348
|
+
finalizeRound(round, endTimestamp);
|
|
1349
|
+
renderRound(round);
|
|
1350
|
+
};
|
|
1351
|
+
|
|
1323
1352
|
/** 新回合接替时立即收掉还在静默期的回合,把两次转场并成一次。 */
|
|
1324
1353
|
const flushSettle = (): void => {
|
|
1325
1354
|
for (const round of [...pendingFoldRounds]) {
|
|
@@ -1460,6 +1489,13 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1460
1489
|
const isUserMessageComponent = (value: any): boolean =>
|
|
1461
1490
|
value instanceof UserMessageComponent || value instanceof SkillInvocationMessageComponent;
|
|
1462
1491
|
|
|
1492
|
+
/**
|
|
1493
|
+
* 回合内的透明行:custom 通知(系统提示、extension 注入)既不是成员也不算外来行。
|
|
1494
|
+
* 它落在两个成员之间时原地保留——不打断统计、不被搬到摘要行下方。
|
|
1495
|
+
* 硬边界仍只有用户消息;summary/compaction 块由各自的 message 边界收口。
|
|
1496
|
+
*/
|
|
1497
|
+
const isTransparentComponent = (value: any): boolean => value instanceof CustomMessageComponent;
|
|
1498
|
+
|
|
1463
1499
|
/**
|
|
1464
1500
|
* 新组件与回合已有成员之间是否隔着一条用户消息。隔着就说明这是下一回合的内容:
|
|
1465
1501
|
* steer 消息插进了回合中间,或上一回合没有正文就收尾、用户又问了新问题。
|
|
@@ -1599,7 +1635,14 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1599
1635
|
const intruders: any[] = [];
|
|
1600
1636
|
for (let i = last - 1; i > first; i--) {
|
|
1601
1637
|
// 用户消息永远留在原地:挪走会把别人的消息挪到用户消息下面去。
|
|
1602
|
-
|
|
1638
|
+
// custom 通知同理:它是回合内的透明行,不被统计也不挪位。
|
|
1639
|
+
if (
|
|
1640
|
+
isMember(children[i]) ||
|
|
1641
|
+
isUserMessageComponent(children[i]) ||
|
|
1642
|
+
isTransparentComponent(children[i])
|
|
1643
|
+
) {
|
|
1644
|
+
continue;
|
|
1645
|
+
}
|
|
1603
1646
|
intruders.unshift(children.splice(i, 1)[0]);
|
|
1604
1647
|
}
|
|
1605
1648
|
if (lastMember) last = children.indexOf(lastMember);
|
|
@@ -1759,22 +1802,24 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1759
1802
|
|
|
1760
1803
|
const thinkingKids: any[] = [];
|
|
1761
1804
|
for (const [component, message] of round.messages) {
|
|
1762
|
-
if (component
|
|
1805
|
+
if (component === previewComponent) {
|
|
1806
|
+
passThroughAssistant(component, message);
|
|
1807
|
+
const kids = Array.isArray(component.contentContainer?.children)
|
|
1808
|
+
? [...component.contentContainer.children]
|
|
1809
|
+
: [];
|
|
1763
1810
|
component.contentContainer?.clear?.();
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
? [...component.contentContainer.children]
|
|
1769
|
-
: [];
|
|
1770
|
-
component.contentContainer?.clear?.();
|
|
1771
|
-
// 只摘思考预览块进槽位;已收尾的思考回收进摘要行。
|
|
1772
|
-
for (const kid of kids) {
|
|
1773
|
-
if (typeof kid?.setHintHovered === "function") thinkingKids.push(kid);
|
|
1811
|
+
// 只摘思考预览块进槽位;已收尾的思考回收进摘要行。
|
|
1812
|
+
for (const kid of kids) {
|
|
1813
|
+
if (typeof kid?.setHintHovered === "function") thinkingKids.push(kid);
|
|
1814
|
+
}
|
|
1774
1815
|
}
|
|
1816
|
+
// text 在各消息自己的位置渲染,不随回合折叠态;非 anchor 成员的
|
|
1817
|
+
// 中止/错误状态仍汇总在 anchor 外层,不在成员处重复成行。
|
|
1818
|
+
renderAssistantWithoutThinking(
|
|
1819
|
+
component,
|
|
1820
|
+
component === round.anchor ? message : { ...message, stopReason: "stop" },
|
|
1821
|
+
);
|
|
1775
1822
|
}
|
|
1776
|
-
// 正文照折叠态渲染(不含 thinking):live 与收尾后同形,收尾只剩槽位卡消失。
|
|
1777
|
-
renderAssistantWithoutThinking(round.anchor, round.messages.get(round.anchor));
|
|
1778
1823
|
|
|
1779
1824
|
const toolsById = new Map<string, any>();
|
|
1780
1825
|
for (const tool of trackedToolComponents) {
|
|
@@ -1900,10 +1945,14 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1900
1945
|
// 否则消息内容里的 thinking([thinking, text, toolCall])会盖到文本上面。
|
|
1901
1946
|
const anchorChildren: any[] = [];
|
|
1902
1947
|
let cardItems: Array<{ child?: any; tool?: any }> = [];
|
|
1903
|
-
|
|
1948
|
+
// 纯 spacer 串拆不开面板,还给它所在消息的组件(通常是上一个
|
|
1949
|
+
// text 块的前导空行)。
|
|
1950
|
+
const flushPanel = (spacerOwner: any = round.anchor) => {
|
|
1904
1951
|
if (cardItems.length === 0) return;
|
|
1905
1952
|
if (cardItems.every((item) => !item.tool && isSpacerComponent(item.child))) {
|
|
1906
|
-
for (const item of cardItems)
|
|
1953
|
+
for (const item of cardItems) {
|
|
1954
|
+
spacerOwner?.contentContainer?.addChild?.(item.child);
|
|
1955
|
+
}
|
|
1907
1956
|
} else {
|
|
1908
1957
|
// 面板上沿留 1 行卡外空行:面板底色与 userMessage 同色,
|
|
1909
1958
|
// 贴在一起会看成一个块;卡内顶部再加 1 行 padding 保持上下对称。
|
|
@@ -1920,13 +1969,15 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1920
1969
|
}
|
|
1921
1970
|
cardItems = [];
|
|
1922
1971
|
};
|
|
1923
|
-
|
|
1972
|
+
// outside 的块(text 及其前导空行)留在各自组件上原位渲染:展开态下
|
|
1973
|
+
// 助手文本不跨消息搬家;它不切断面板——cardItems 跨消息延续,回合的
|
|
1974
|
+
// thinking/工具统一汇成末尾的一张共享面板,混排 text 不再把卡撕成几段。
|
|
1975
|
+
const takeChild = (component: any, child: any, outside: boolean) => {
|
|
1924
1976
|
if (!outside) {
|
|
1925
1977
|
cardItems.push({ child });
|
|
1926
1978
|
return;
|
|
1927
1979
|
}
|
|
1928
|
-
|
|
1929
|
-
anchorChildren.push(child);
|
|
1980
|
+
component.contentContainer?.addChild?.(child);
|
|
1930
1981
|
};
|
|
1931
1982
|
const placedToolIds = new Set<string>();
|
|
1932
1983
|
const placeTool = (id: string, fallbackName?: string) => {
|
|
@@ -1972,11 +2023,11 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1972
2023
|
}
|
|
1973
2024
|
}
|
|
1974
2025
|
while (cursor.i < kids.length) local.push({ outside: false, child: kids[cursor.i++] });
|
|
1975
|
-
for (const entry of local) if (entry.outside) takeChild(entry.child, true);
|
|
2026
|
+
for (const entry of local) if (entry.outside) takeChild(component, entry.child, true);
|
|
1976
2027
|
for (const entry of local) {
|
|
1977
2028
|
if (entry.outside) continue;
|
|
1978
2029
|
if (entry.toolId) placeTool(entry.toolId, entry.toolName);
|
|
1979
|
-
else takeChild(entry.child, false);
|
|
2030
|
+
else takeChild(component, entry.child, false);
|
|
1980
2031
|
}
|
|
1981
2032
|
}
|
|
1982
2033
|
for (const message of round.detachedMessages) {
|
|
@@ -2001,15 +2052,10 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
2001
2052
|
!explicitRoundToolIds.has(String(tool.toolCallId ?? "")),
|
|
2002
2053
|
);
|
|
2003
2054
|
flushPanel();
|
|
2055
|
+
// anchorChildren 只剩面板件(spacer+panelBoundSpacer 都是 childAtRow 组件):
|
|
2056
|
+
// 面板挂回摘要行原位,text 已在各自组件上。
|
|
2004
2057
|
if (mounted) {
|
|
2005
|
-
|
|
2006
|
-
const outside: any[] = [];
|
|
2007
|
-
const panel: any[] = [];
|
|
2008
|
-
for (const child of anchorChildren) {
|
|
2009
|
-
(typeof child?.childAtRow === "function" ? panel : outside).push(child);
|
|
2010
|
-
}
|
|
2011
|
-
for (const child of outside) round.anchor.contentContainer.addChild(child);
|
|
2012
|
-
if (panel.length > 0) mountRoundTail(round, () => panel);
|
|
2058
|
+
if (anchorChildren.length > 0) mountRoundTail(round, () => anchorChildren);
|
|
2013
2059
|
else unmountRoundTail(round);
|
|
2014
2060
|
} else {
|
|
2015
2061
|
unmountRoundTail(round);
|
|
@@ -2041,7 +2087,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
2041
2087
|
// 折叠时工具行被隐藏:abort/error/length 必须挂在摘要外层。
|
|
2042
2088
|
appendStopStatus(component, stopStatus);
|
|
2043
2089
|
} else {
|
|
2044
|
-
|
|
2090
|
+
// 成员的 text 同样留在自己组件上;中止状态只在 anchor 外层报一次。
|
|
2091
|
+
renderAssistantWithoutThinking(component, { ...message, stopReason: "stop" });
|
|
2045
2092
|
}
|
|
2046
2093
|
}
|
|
2047
2094
|
};
|
|
@@ -2143,6 +2190,15 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
2143
2190
|
|
|
2144
2191
|
const content = Array.isArray(message?.content) ? message.content : [];
|
|
2145
2192
|
const hasToolCalls = content.some((item: any) => item?.type === "toolCall");
|
|
2193
|
+
const hasOuterToolCall = content.some(
|
|
2194
|
+
(item: any) => item?.type === "toolCall" && OUTER_TOOLS.has(String(item?.name ?? "")),
|
|
2195
|
+
);
|
|
2196
|
+
const hasInnerToolCall = content.some(
|
|
2197
|
+
(item: any) => item?.type === "toolCall" && !OUTER_TOOLS.has(String(item?.name ?? "")),
|
|
2198
|
+
);
|
|
2199
|
+
const hasThinkingContent = content.some(
|
|
2200
|
+
(item: any) => item?.type === "thinking" && String(item?.thinking ?? "").trim(),
|
|
2201
|
+
);
|
|
2146
2202
|
const hasText = content.some(
|
|
2147
2203
|
(item: any) => item?.type === "text" && typeof item.text === "string" && item.text.trim(),
|
|
2148
2204
|
);
|
|
@@ -2150,13 +2206,54 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
2150
2206
|
|
|
2151
2207
|
if (hasToolCalls) {
|
|
2152
2208
|
let round = roundByComponent.get(this);
|
|
2209
|
+
const previousMessage = round?.messages.get(this);
|
|
2210
|
+
const previousHadOuter = Array.isArray(previousMessage?.content)
|
|
2211
|
+
? previousMessage.content.some(
|
|
2212
|
+
(item: any) => item?.type === "toolCall" && OUTER_TOOLS.has(String(item?.name ?? "")),
|
|
2213
|
+
)
|
|
2214
|
+
: false;
|
|
2153
2215
|
if (!round) {
|
|
2154
|
-
//
|
|
2155
|
-
//
|
|
2156
|
-
//
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2216
|
+
// 外层行打断摘要段:本条消息带外层调用且可并入的活动回合已有内容时,
|
|
2217
|
+
// 旧回合就地完结(不走静默期)。本条消息自己含 inner 调用或思考才另起一段;
|
|
2218
|
+
// 外层独段(edit-only 消息)不建回合——没有可汇总内容,建段只会挂空摘要行。
|
|
2219
|
+
const joinable = joinableActiveRound(this);
|
|
2220
|
+
if (joinable && hasOuterToolCall) {
|
|
2221
|
+
sealRound(joinable, message?.timestamp);
|
|
2222
|
+
if (hasInnerToolCall || hasThinkingContent) {
|
|
2223
|
+
round = createRound(this, message);
|
|
2224
|
+
roundByComponent.set(this, round);
|
|
2225
|
+
round.messages.set(this, message);
|
|
2226
|
+
if (isUsableTimestamp(message.timestamp) && message.timestamp < round.startedAt) {
|
|
2227
|
+
round.startedAt = message.timestamp;
|
|
2228
|
+
}
|
|
2229
|
+
renderRound(round);
|
|
2230
|
+
return undefined;
|
|
2231
|
+
}
|
|
2232
|
+
// 外层独段:text/thinking 照原生渲染,edit 行由 tool render 外置,没有摘要行。
|
|
2233
|
+
return passThroughAssistant(this, message);
|
|
2234
|
+
}
|
|
2235
|
+
if (hasInnerToolCall || hasThinkingContent) {
|
|
2236
|
+
// 本条消息是否带 text 不是回合边界:pi 流式协议里同一条
|
|
2237
|
+
// assistant 消息会以 text+toolCall、toolCall-only 反复到达。
|
|
2238
|
+
// 只在「组件未挂进任何回合」时并入 activeRound;被用户消息
|
|
2239
|
+
// 隔开(steer/上回合没正文)时由 joinOrCreateRound 收尾另起。
|
|
2240
|
+
round = joinOrCreateRound(this, message);
|
|
2241
|
+
roundByComponent.set(this, round);
|
|
2242
|
+
round.messages.set(this, message);
|
|
2243
|
+
if (isUsableTimestamp(message.timestamp) && message.timestamp < round.startedAt) {
|
|
2244
|
+
round.startedAt = message.timestamp;
|
|
2245
|
+
}
|
|
2246
|
+
renderRound(round);
|
|
2247
|
+
return undefined;
|
|
2248
|
+
}
|
|
2249
|
+
// 外层独段、又没有可并入的活动回合:不建回合,text/工具各归其位。
|
|
2250
|
+
if (activeRound) finishRound(activeRound, message?.timestamp);
|
|
2251
|
+
return passThroughAssistant(this, message);
|
|
2252
|
+
}
|
|
2253
|
+
if (hasOuterToolCall && !previousHadOuter && roundLive(round)) {
|
|
2254
|
+
// 消息入段后流式增长才冒出外层调用(先 thinking 后 toolCall):
|
|
2255
|
+
// 就地封口所在段,本条消息按消息级归属留在原段,后续消息另起一段。
|
|
2256
|
+
sealRound(round, message?.timestamp);
|
|
2160
2257
|
}
|
|
2161
2258
|
round.messages.set(this, message);
|
|
2162
2259
|
if (isUsableTimestamp(message.timestamp) && message.timestamp < round.startedAt) {
|
|
@@ -50,8 +50,8 @@ import {
|
|
|
50
50
|
hideScrollButton,
|
|
51
51
|
dockScrollButtonActive,
|
|
52
52
|
restoreOfficialScrollToEnd,
|
|
53
|
+
restoreNativeScrollIndicatorText,
|
|
53
54
|
syncOfficialScrollToEnd,
|
|
54
|
-
isScrollBottomInput,
|
|
55
55
|
renderScrollButton,
|
|
56
56
|
resetScrollButtonState,
|
|
57
57
|
scheduleScrollButtonSync,
|
|
@@ -78,6 +78,7 @@ import {
|
|
|
78
78
|
} from "./hover.ts";
|
|
79
79
|
import {
|
|
80
80
|
handleStickyPromptClick,
|
|
81
|
+
stickyBarHit,
|
|
81
82
|
resetStickyPromptState,
|
|
82
83
|
updateStickyPromptHover,
|
|
83
84
|
} from "./sticky-prompt.ts";
|
|
@@ -613,6 +614,18 @@ function packetInsideOverlay(tui: any, packet: SgrMousePacket): boolean {
|
|
|
613
614
|
return false;
|
|
614
615
|
}
|
|
615
616
|
|
|
617
|
+
/** 原生指示器优先于工具卡;覆盖它的粘性行仍保留原有点击优先级。 */
|
|
618
|
+
function packetOnNativeIndicator(tui: any, packet: SgrMousePacket): boolean {
|
|
619
|
+
const rect = tui.scrollToEndIndicatorRect;
|
|
620
|
+
return Boolean(
|
|
621
|
+
rect &&
|
|
622
|
+
packet.row - 1 === rect.row &&
|
|
623
|
+
packet.col - 1 >= rect.column &&
|
|
624
|
+
packet.col - 1 < rect.column + rect.width &&
|
|
625
|
+
!stickyBarHit(tui, packet),
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
616
629
|
/**
|
|
617
630
|
* 实例级包装 TuiAltScreen.handleViewportInput(惰性 Proxy 安全):
|
|
618
631
|
* 原型方法取 original(绕开 proxy 函数包装),实例 own property 装 wrapper
|
|
@@ -636,6 +649,13 @@ function patchFullscreenViewportInput(tui: any): void {
|
|
|
636
649
|
// dispatchMouseToOverlay → overlay.handleMouse;overlay 外的包仍
|
|
637
650
|
// 走扩展收起/展开/hover。两条路径各管各的。
|
|
638
651
|
if (packets) {
|
|
652
|
+
const nativePacket = packets.find((packet) => packetOnNativeIndicator(tui, packet));
|
|
653
|
+
if (nativePacket) {
|
|
654
|
+
clearPendingCollapsePress();
|
|
655
|
+
applyFullscreenHover(tui, null);
|
|
656
|
+
updateStickyPromptHover(tui, nativePacket);
|
|
657
|
+
return Reflect.apply(original, this, [data]);
|
|
658
|
+
}
|
|
639
659
|
// 布局还没出来或仍是上一帧:不要把点击收成工具展开。返回 undefined
|
|
640
660
|
// 让焦点 overlay 的 handleInput 拿到原始 SGR;官方链也靠这份布局,调它会消费且打到 transcript。
|
|
641
661
|
if (tui.hasOverlay?.() && !overlayLayoutsCurrent(tui)) return undefined;
|
|
@@ -985,13 +1005,9 @@ function handleToolMouseInput(data: string): { consume: true } | undefined {
|
|
|
985
1005
|
if (!getToolMouseTui()) return undefined;
|
|
986
1006
|
// 惰性 Proxy fullscreen:鼠标由 handleViewportInput 包装消费(官方链之前),
|
|
987
1007
|
// 此处只处理键盘(鼠标事件在官方 listener 已被 consume,到不了这里)。
|
|
1008
|
+
// 回底键归 Pi viewport(tui.altScreen.bottom,overlay 聚焦时让给 overlay),不在此重复处理。
|
|
988
1009
|
if (fullscreenLazyTui(getToolMouseTui())) {
|
|
989
1010
|
scheduleScrollButtonSync(getToolMouseTui(), data);
|
|
990
|
-
if (isScrollBottomInput(data)) {
|
|
991
|
-
getToolMouseTui().scrollToBottom?.();
|
|
992
|
-
hideScrollButton(getToolMouseTui());
|
|
993
|
-
return { consume: true };
|
|
994
|
-
}
|
|
995
1011
|
return undefined;
|
|
996
1012
|
}
|
|
997
1013
|
updateScrollButtonFromInput(getToolMouseTui(), data);
|
|
@@ -1080,6 +1096,7 @@ export function resetToolHoverState(): void {
|
|
|
1080
1096
|
setScrollButtonVisible(false);
|
|
1081
1097
|
setScrollButtonHovered(false);
|
|
1082
1098
|
resetStickyPromptState();
|
|
1099
|
+
restoreNativeScrollIndicatorText();
|
|
1083
1100
|
restoreOfficialScrollToEnd(getToolMouseTui());
|
|
1084
1101
|
releaseFullscreenToolMouseMotion(getToolMouseTui());
|
|
1085
1102
|
}
|
|
@@ -1172,6 +1189,7 @@ export function installToolMouseInteraction(
|
|
|
1172
1189
|
return renderScrollButton(width, theme);
|
|
1173
1190
|
},
|
|
1174
1191
|
invalidate() {},
|
|
1192
|
+
dispose: restoreNativeScrollIndicatorText,
|
|
1175
1193
|
});
|
|
1176
1194
|
return getScrollButtonWidget();
|
|
1177
1195
|
}
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
|
|
18
18
|
const ZENTUI_PAGE_UP_INPUT = /^\x1b\[5;9(?::[12])?~$|^\x1b\[57421;9(?::[12])?u$|^\x1b\[1;6A$/;
|
|
19
19
|
const ZENTUI_PAGE_DOWN_INPUT = /^\x1b\[6;9(?::[12])?~$|^\x1b\[57422;9(?::[12])?u$|^\x1b\[1;6B$/;
|
|
20
|
-
const
|
|
20
|
+
const SCROLL_BOTTOM_KEYBINDING = "tui.altScreen.bottom";
|
|
21
21
|
|
|
22
22
|
patchRegistry.ensure(TOOL_MOUSE_TUI_SLOT, () => null);
|
|
23
23
|
export function getToolMouseTui(): any {
|
|
@@ -68,6 +68,14 @@ export function setScrollButtonVisible(visible: boolean): void {
|
|
|
68
68
|
export function noteNewTranscriptItem(): void {
|
|
69
69
|
const state = scrollButtonState();
|
|
70
70
|
const tui = getToolMouseTui();
|
|
71
|
+
if (nativeScrollTextEnabled(tui)) {
|
|
72
|
+
syncOfficialScrollToEnd(tui);
|
|
73
|
+
if (nativeScrollTextPatch && !isFullscreenAtBottom(tui)) {
|
|
74
|
+
state.newCount += 1;
|
|
75
|
+
tui.requestRender?.();
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
71
79
|
if (!dockScrollButtonActive() || !state.visible || !tui) return;
|
|
72
80
|
// 提交新消息时官方可能直接跳回底部(不经过滚动输入):先按实时跟随状态校正,
|
|
73
81
|
// 否则跟随期间的内容会被当成“离开底部时的新消息”计入。
|
|
@@ -137,34 +145,78 @@ export function restoreOfficialScrollToEnd(tui: any): void {
|
|
|
137
145
|
tui[OFFICIAL_SCROLL_TO_END_KEY] = undefined;
|
|
138
146
|
}
|
|
139
147
|
|
|
140
|
-
/** dock 模式替换官方浮层;native
|
|
148
|
+
/** dock 模式替换官方浮层;native 保留 Pi 原生浮层并换上 dock 文案;ccstyle off 还原 Pi 原样。 */
|
|
141
149
|
export function syncOfficialScrollToEnd(tui: any): void {
|
|
150
|
+
if (!nativeScrollTextEnabled(tui)) restoreNativeScrollIndicatorText();
|
|
142
151
|
if (dockScrollButtonActive()) {
|
|
143
152
|
disableOfficialScrollToEnd(tui);
|
|
144
153
|
return;
|
|
145
154
|
}
|
|
146
155
|
restoreOfficialScrollToEnd(tui);
|
|
156
|
+
if (nativeScrollTextEnabled(tui)) {
|
|
157
|
+
// 离开 dock 时清零一次;native 的后续渲染保留共享计数。
|
|
158
|
+
if (getScrollButtonVisible()) setScrollButtonVisible(false);
|
|
159
|
+
setScrollButtonHovered(false);
|
|
160
|
+
syncNativeScrollIndicatorText(tui);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
147
163
|
setScrollButtonVisible(false);
|
|
148
164
|
setScrollButtonHovered(false);
|
|
149
165
|
}
|
|
150
166
|
|
|
167
|
+
/** 只记录文案补丁;dock 的显隐、快捷键和原生浮层开关仍走原路径。 */
|
|
168
|
+
let nativeScrollTextPatch: { renderer: any; original: () => string } | undefined;
|
|
169
|
+
|
|
170
|
+
function nativeScrollTextEnabled(tui: any): boolean {
|
|
171
|
+
return (
|
|
172
|
+
toolMouseInteractionActive() && config.scrollButtonMode === "native" && fullscreenLazyTui(tui)
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** widget 销毁或离开 native 模式时还原 Pi 的原始回调。 */
|
|
177
|
+
export function restoreNativeScrollIndicatorText(): void {
|
|
178
|
+
if (!nativeScrollTextPatch) return;
|
|
179
|
+
const { renderer, original } = nativeScrollTextPatch;
|
|
180
|
+
renderer.scrollToEndIndicator = original;
|
|
181
|
+
nativeScrollTextPatch = undefined;
|
|
182
|
+
scrollButtonState().newCount = 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function syncNativeScrollIndicatorText(tui: any): void {
|
|
186
|
+
// Pi 的稳定 Proxy 会重绑捕获的方法;文案回调必须从真实 renderer 上取得。
|
|
187
|
+
const renderer = tui.valueOf();
|
|
188
|
+
if (nativeScrollTextPatch?.renderer !== renderer) restoreNativeScrollIndicatorText();
|
|
189
|
+
if (!nativeScrollTextPatch) {
|
|
190
|
+
const original = renderer.scrollToEndIndicator;
|
|
191
|
+
if (typeof original !== "function") return;
|
|
192
|
+
nativeScrollTextPatch = { renderer, original };
|
|
193
|
+
renderer.scrollToEndIndicator = () =>
|
|
194
|
+
original.call(renderer).replace("Jump to latest message", scrollButtonText());
|
|
195
|
+
}
|
|
196
|
+
if (isFullscreenAtBottom(renderer)) scrollButtonState().newCount = 0;
|
|
197
|
+
}
|
|
198
|
+
|
|
151
199
|
/** 官方 fullscreen:是否已跟随 transcript 底部(按钮隐藏条件)。 */
|
|
152
200
|
export function isFullscreenAtBottom(tui: any): boolean {
|
|
153
201
|
const following = tui.isFollowingOutput ?? tui.getPrimaryScrollView?.()?.isFollowingEnd ?? true;
|
|
154
202
|
return Boolean(following);
|
|
155
203
|
}
|
|
156
204
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
.
|
|
161
|
-
|
|
205
|
+
/** 与 Pi 原生浮层同源(keyDisplayText):用户实际绑定,首字母大写,macOS 上 alt 显示为 option。 */
|
|
206
|
+
function scrollBottomShortcutText(): string {
|
|
207
|
+
return getKeybindings()
|
|
208
|
+
.getKeys(SCROLL_BOTTOM_KEYBINDING)
|
|
209
|
+
.map((key) =>
|
|
210
|
+
key
|
|
211
|
+
.split("+")
|
|
212
|
+
.map((part) => {
|
|
213
|
+
const display =
|
|
214
|
+
process.platform === "darwin" && part.toLowerCase() === "alt" ? "option" : part;
|
|
215
|
+
return display.charAt(0).toUpperCase() + display.slice(1);
|
|
216
|
+
})
|
|
217
|
+
.join("+"),
|
|
162
218
|
)
|
|
163
|
-
.join("
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
export function isScrollBottomInput(data: string): boolean {
|
|
167
|
-
return matchesKey(data, SCROLL_BOTTOM_SHORTCUT);
|
|
219
|
+
.join("/");
|
|
168
220
|
}
|
|
169
221
|
|
|
170
222
|
function isScrollNavigationInput(data: string): boolean {
|
|
@@ -251,9 +303,8 @@ export function updateScrollButtonFromInput(tui: any, data: string): void {
|
|
|
251
303
|
/** 无新内容时提示原文案;有新内容时换成累计条数。 */
|
|
252
304
|
function scrollButtonText(): string {
|
|
253
305
|
const count = getScrollButtonNewCount();
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
return `${count} new message${count === 1 ? "" : "s"} · ${shortcut}`;
|
|
306
|
+
if (count <= 0) return "Back to bottom";
|
|
307
|
+
return `${count} new message${count === 1 ? "" : "s"}`;
|
|
257
308
|
}
|
|
258
309
|
|
|
259
310
|
export function renderScrollButton(width: number, theme: any): string[] {
|
|
@@ -263,9 +314,11 @@ export function renderScrollButton(width: number, theme: any): string[] {
|
|
|
263
314
|
!fullscreenLazyTui(getToolMouseTui())
|
|
264
315
|
)
|
|
265
316
|
return [];
|
|
317
|
+
// 未绑定时与 Pi 原生一样省略快捷键段。
|
|
318
|
+
const shortcut = scrollBottomShortcutText();
|
|
266
319
|
const label = theme.fg(
|
|
267
320
|
getScrollButtonHovered() ? "text" : "accent",
|
|
268
|
-
`[ ↓ ${scrollButtonText()} ]`,
|
|
321
|
+
`[ ↓ ${scrollButtonText()}${shortcut ? ` · ${shortcut}` : ""} ]`,
|
|
269
322
|
);
|
|
270
323
|
const leftPad = Math.max(0, Math.floor((width - visibleWidth(label)) / 2));
|
|
271
324
|
return [`${" ".repeat(leftPad)}${truncateToWidth(label, width, "…")}`];
|
|
@@ -296,7 +296,7 @@ export function renderStickyPromptScreen(
|
|
|
296
296
|
}
|
|
297
297
|
|
|
298
298
|
/** 行与列都落在本帧画出的粘性条内。滚动条那一列在条外。 */
|
|
299
|
-
function stickyBarHit(tui: any, packet: SgrMousePacket): boolean {
|
|
299
|
+
export function stickyBarHit(tui: any, packet: SgrMousePacket): boolean {
|
|
300
300
|
const state = stickyState();
|
|
301
301
|
if (!stickyPromptActive() || !state.visible || state.layout !== tui?.currentLayout) return false;
|
|
302
302
|
if (packet.row - 1 !== state.row) return false;
|