@bachi/pi-coder 1.0.0
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/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/config/AGENTS.md +100 -0
- package/config/pi-statusline.json +140 -0
- package/config/settings.json +38 -0
- package/config/web-search.json +5 -0
- package/docs/README.md +14 -0
- package/docs/configuration.md +123 -0
- package/docs/development.md +177 -0
- package/docs/extensions.md +292 -0
- package/docs/handbook.zh.md +432 -0
- package/docs/installation.md +124 -0
- package/docs/themes.md +107 -0
- package/extensions/ask-user-question/answers.test.ts +104 -0
- package/extensions/ask-user-question/answers.ts +72 -0
- package/extensions/ask-user-question/dialog.test.ts +180 -0
- package/extensions/ask-user-question/dialog.ts +102 -0
- package/extensions/ask-user-question/index.ts +253 -0
- package/extensions/ask-user-question/model.test.ts +275 -0
- package/extensions/ask-user-question/model.ts +259 -0
- package/extensions/ask-user-question/schema.ts +49 -0
- package/extensions/ask-user-question/types.ts +86 -0
- package/extensions/ask-user-question/validate.test.ts +183 -0
- package/extensions/ask-user-question/validate.ts +110 -0
- package/extensions/ask-user-question/view.ts +262 -0
- package/extensions/auto-default-model/default-model.test.ts +268 -0
- package/extensions/auto-default-model/index.ts +87 -0
- package/extensions/bash-command-collapse.ts +1476 -0
- package/extensions/below-editor-after-statusline.ts +118 -0
- package/extensions/clear-command.ts +29 -0
- package/extensions/cwd-statusline.ts +39 -0
- package/extensions/exit-command.ts +59 -0
- package/extensions/fenceless-code-block/index.test.ts +208 -0
- package/extensions/fenceless-code-block/index.ts +28 -0
- package/extensions/fenceless-code-block/render.test.ts +177 -0
- package/extensions/fenceless-code-block/render.ts +142 -0
- package/extensions/folder-history.ts +197 -0
- package/extensions/init-command.ts +163 -0
- package/extensions/prompt-editor/bash-prompt.test.ts +94 -0
- package/extensions/prompt-editor/bash-prompt.ts +59 -0
- package/extensions/prompt-editor/render.test.ts +283 -0
- package/extensions/prompt-editor.ts +212 -0
- package/extensions/read-path-collapse.ts +474 -0
- package/extensions/recap/index.test.ts +348 -0
- package/extensions/recap/index.ts +462 -0
- package/extensions/recap/subagents.test.ts +144 -0
- package/extensions/recap/subagents.ts +128 -0
- package/extensions/rewind/README.md +229 -0
- package/extensions/rewind/checkpoints.test.ts +560 -0
- package/extensions/rewind/checkpoints.ts +820 -0
- package/extensions/rewind/flow.test.ts +756 -0
- package/extensions/rewind/flow.ts +362 -0
- package/extensions/rewind/index.ts +400 -0
- package/extensions/rewind/picker.ts +135 -0
- package/extensions/rewind/viewport.test.ts +76 -0
- package/extensions/rewind/viewport.ts +48 -0
- package/extensions/simple-task/gap.test.ts +147 -0
- package/extensions/simple-task/gap.ts +122 -0
- package/extensions/simple-task/index.ts +439 -0
- package/extensions/simple-task/types.ts +53 -0
- package/extensions/simple-task/widget.ts +86 -0
- package/extensions/startup-logo/header-guard.test.ts +274 -0
- package/extensions/startup-logo/header-guard.ts +166 -0
- package/extensions/startup-logo/index.test.ts +305 -0
- package/extensions/startup-logo/index.ts +194 -0
- package/extensions/startup-logo/loaded-sections.test.ts +257 -0
- package/extensions/startup-logo/loaded-sections.ts +267 -0
- package/extensions/startup-logo/logo.test.ts +124 -0
- package/extensions/startup-logo/logo.ts +124 -0
- package/extensions/statusline/footer-guard.test.ts +273 -0
- package/extensions/statusline/footer-guard.ts +171 -0
- package/extensions/statusline/git.test.ts +174 -0
- package/extensions/statusline/git.ts +142 -0
- package/extensions/statusline/index.ts +294 -0
- package/extensions/statusline/line.test.ts +316 -0
- package/extensions/statusline/line.ts +201 -0
- package/extensions/subagent-log-guard/filter.test.ts +85 -0
- package/extensions/subagent-log-guard/filter.ts +32 -0
- package/extensions/subagent-log-guard/index.ts +112 -0
- package/extensions/theme-command.ts +263 -0
- package/extensions/thinking-collapse/window.test.ts +321 -0
- package/extensions/thinking-collapse/window.ts +354 -0
- package/extensions/thinking-collapse.ts +60 -0
- package/extensions/tool-diff/title-row.test.ts +254 -0
- package/extensions/tool-diff/title-row.ts +191 -0
- package/extensions/tool-diff.ts +1276 -0
- package/extensions/working-indicator/bash-spinner.test.ts +135 -0
- package/extensions/working-indicator/bash-spinner.ts +114 -0
- package/extensions/working-indicator/index.test.ts +579 -0
- package/extensions/working-indicator/index.ts +940 -0
- package/extensions/working-indicator/spinner-frames.test.ts +219 -0
- package/extensions/working-indicator/spinner-frames.ts +156 -0
- package/extensions/working-indicator/summary-request.test.ts +195 -0
- package/extensions/working-indicator/summary-request.ts +207 -0
- package/extensions/working-indicator/working-summary.test.ts +499 -0
- package/extensions/working-indicator/working-summary.ts +375 -0
- package/package.json +71 -0
- package/themes/ayu.json +97 -0
- package/themes/catppuccin.json +103 -0
- package/themes/summer-night.json +87 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* subagent-log-guard —— 别让 pi-subagents 的启动诊断糊在输入框上。
|
|
3
|
+
*
|
|
4
|
+
* 现象:跑一个**前台**子代理时,输入框那一行(`❯ ` 之后)突然被
|
|
5
|
+
* [pi-subagents] Agent 'researcher': host runtime tool availability omitted [web_search, …]
|
|
6
|
+
* 覆盖,widget 的后续行被这行字的续行冲掉、底色错位,看着像输入框坏了;回车 / 切窗口都刷不掉脏字。
|
|
7
|
+
*
|
|
8
|
+
* 根因(本机 tmux 复现过):pi-subagents 把这行诊断交给 console.warn 打印
|
|
9
|
+
* (`runs/foreground/execution.ts` 的 launch warnings、`runs/background/subagent-runner.ts` 的同名诊断等)。
|
|
10
|
+
* 父进程里 console.warn 写的是**真 stderr** —— pi 只在 rpc / json / print 模式下 takeOverStdout
|
|
11
|
+
* (`dist/main.js`:`shouldTakeOverStdout = appMode !== "interactive"`),交互模式不接管扩展的 console
|
|
12
|
+
* 输出。于是这行字被写进 TUI 的备用屏,落点是硬件光标(编辑器里 `❯ ` 之后那一列);而 pi-tui 的
|
|
13
|
+
* 差分渲染器认为这些行没变、不会重画,脏字就留在屏幕上了。
|
|
14
|
+
* 复现:`pi -ne -e` 一个「2.5 秒后 console.warn 一行 [pi-subagents] …」的探针扩展,
|
|
15
|
+
* 6 秒后 `tmux capture-pane` 能看到同样的脏行。
|
|
16
|
+
*
|
|
17
|
+
* 本扩展只做一件事:在**带 UI 的进程**里包一层 process.stderr.write,凡是以 `[pi-subagents]`
|
|
18
|
+
* 开头的写入(判定见 filter.ts)一律不进终端。
|
|
19
|
+
* - 默认:丢弃 —— 这些诊断(「宿主没提供你 tools: 里声明的扩展工具」之类)都是非致命的,
|
|
20
|
+
* 代价是屏幕上那坨脏字,直接关掉最干净。
|
|
21
|
+
* - `PI_SUBAGENT_LOG_GUARD=notify`:不丢,改成 ctx.ui.notify(..., "warning") 由 pi 自己排版 ——
|
|
22
|
+
* 信息还在、但不会再糊屏。想保留「async spawn failed」这类父进程错误信息时用它。
|
|
23
|
+
* - `PI_SUBAGENT_LOG_GUARD=off`:整层拦网不装(回到脏行的老行为),排查「这行到底谁打的」时用。
|
|
24
|
+
* - `ctx.hasUI === false` 的进程(RPC / print / cron / 异步子代理 runner)**不装**:那些进程没有
|
|
25
|
+
* TUI 可污染,异步子代理的诊断照样落在 `runner.stderr.log` 里,行为与以前完全一致。
|
|
26
|
+
* - 只拦这个前缀:别的写 stderr 的内容(pi 自己的崩溃处理、其它扩展)原样放行。
|
|
27
|
+
*
|
|
28
|
+
* 前台子代理是**同进程**跑的(`runs/shared/child-session.ts` 用 pi 的 createAgentSession 建会话、
|
|
29
|
+
* 与宿主共用 ModelRuntime),所以子会话里任何扩展的 console.warn 也会经过这层拦网。
|
|
30
|
+
*
|
|
31
|
+
* 为什么不去绕开触发条件(不用那些在 tools: 里声明扩展工具的 agent,如内置的 researcher):那等于
|
|
32
|
+
* 放弃这类 agent,而这个坑的通用形态是「交互模式下任何扩展 console.warn 都会糊屏」,只是目前只有
|
|
33
|
+
* pi-subagents 常这么干。上游的修法是调用点加 `if (!ctx.hasUI)`(pi 内核自己就是这么写的),或改走
|
|
34
|
+
* ctx.ui.notify;本扩展是本地兜底,上游修好之后它也只是拦不到东西而已,没有副作用。
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
38
|
+
import { chunkToText, splitSubagentDiagnostics } from "./filter.ts";
|
|
39
|
+
|
|
40
|
+
/** /reload 会重新执行本文件;包好的 write 只能有一份,所以状态挂在 process 的 symbol 上复用。 */
|
|
41
|
+
const STATE_KEY = Symbol.for("pi.subagent-log-guard.state");
|
|
42
|
+
|
|
43
|
+
/** 三种模式:未设置/其它值 = 丢弃(默认);`notify` = 改走 pi 的通知;`off` = 整层不装。 */
|
|
44
|
+
const MODE = (process.env.PI_SUBAGENT_LOG_GUARD ?? "").trim().toLowerCase();
|
|
45
|
+
const DISABLED = MODE === "off";
|
|
46
|
+
const NOTIFY_MODE = MODE === "notify";
|
|
47
|
+
|
|
48
|
+
interface GuardState {
|
|
49
|
+
/** 当前会话的通知出口;没有活跃会话时是 undefined(= 直接丢)。 */
|
|
50
|
+
sink: ((text: string) => void) | undefined;
|
|
51
|
+
/** 已拦下的诊断条数,仅用于排查(不上屏、不落盘)。 */
|
|
52
|
+
captured: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function guardState(): GuardState | undefined {
|
|
56
|
+
return (globalThis as unknown as Record<symbol, GuardState | undefined>)[STATE_KEY];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 包 process.stderr.write。幂等:/reload 后复用同一份状态,不会层层嵌套。
|
|
61
|
+
* 放行时把参数原样转发(chunk / encoding / callback),所以对 pi 与其它扩展完全透明。
|
|
62
|
+
*/
|
|
63
|
+
function installGuard(): GuardState {
|
|
64
|
+
const existing = guardState();
|
|
65
|
+
if (existing) return existing;
|
|
66
|
+
|
|
67
|
+
const state: GuardState = { sink: undefined, captured: 0 };
|
|
68
|
+
const originalWrite = process.stderr.write.bind(process.stderr);
|
|
69
|
+
|
|
70
|
+
// 参数签名按 Node 的 write(chunk, encoding?, callback?) 原样转发;拦下时自己回调,
|
|
71
|
+
// 免得调用方(console 内部 / 别的扩展)以为永远没 flush。回调按 Node 惯例异步触发。
|
|
72
|
+
const guard = (chunk: string | Uint8Array, ...rest: unknown[]): boolean => {
|
|
73
|
+
const { passThrough, captured } = splitSubagentDiagnostics(chunkToText(chunk));
|
|
74
|
+
if (captured.length === 0) {
|
|
75
|
+
return (originalWrite as (...args: unknown[]) => boolean)(chunk, ...rest);
|
|
76
|
+
}
|
|
77
|
+
state.captured += captured.length;
|
|
78
|
+
const text = captured.join(" ").trim();
|
|
79
|
+
if (text) state.sink?.(text);
|
|
80
|
+
const callback = rest.find((arg) => typeof arg === "function") as
|
|
81
|
+
| ((error?: Error | null) => void)
|
|
82
|
+
| undefined;
|
|
83
|
+
if (callback) queueMicrotask(() => callback(null));
|
|
84
|
+
return true;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
process.stderr.write = guard as typeof process.stderr.write;
|
|
88
|
+
(globalThis as unknown as Record<symbol, GuardState | undefined>)[STATE_KEY] = state;
|
|
89
|
+
return state;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export default function subagentLogGuard(pi: ExtensionAPI): void {
|
|
93
|
+
pi.on("session_start", (_event, ctx) => {
|
|
94
|
+
// 无 UI 的进程(RPC / print / 异步 runner)不需要这层:stderr 本来就是它们的日志出口。
|
|
95
|
+
if (DISABLED || !ctx.hasUI) return;
|
|
96
|
+
const state = installGuard();
|
|
97
|
+
state.sink = NOTIFY_MODE
|
|
98
|
+
? (text) => {
|
|
99
|
+
try {
|
|
100
|
+
ctx.ui.notify(text, "warning");
|
|
101
|
+
} catch {
|
|
102
|
+
// 换会话的窗口里 ctx 可能已经失效,丢掉这条即可,别把宿主带崩。
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
: undefined;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
pi.on("session_shutdown", () => {
|
|
109
|
+
const state = guardState();
|
|
110
|
+
if (state) state.sink = undefined;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* theme-command — /theme 指令:列出全部主题,上下键**实时预览**,回车落盘,Esc 取消。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它:内置只有 `/settings` → Theme 这条深路径(还混着 light/dark 自动模式),
|
|
5
|
+
* 想换皮肤要点好几层。`/theme` 是一步直达的选择器,而且带预览。
|
|
6
|
+
*
|
|
7
|
+
* 核心是 `ctx.ui.setTheme()` 的**两条路径语义完全不同**(interactive-mode.js 的 ExtensionUIContext
|
|
8
|
+
* + theme-controller.js):
|
|
9
|
+
* - 传 **Theme 对象** → `setThemeInstance()` → 只在内存里换色,**不写 settings.json**;
|
|
10
|
+
* - 传 **名字字符串** → `setThemeName()` → 应用 **且立刻写 settings.json**(settingsManager.save())。
|
|
11
|
+
* 所以预览必须走对象路径,只有回车确认才走名字路径 —— Esc 取消后 settings.json 全程没被动过。
|
|
12
|
+
*
|
|
13
|
+
* 交互流程:
|
|
14
|
+
* - `/theme` → 打开选择器(SelectList + 色板样本区),上下键即预览
|
|
15
|
+
* - `/theme <name>` → 直接切换并落盘(名字不存在就报错并列出可用主题)
|
|
16
|
+
* - 非 TUI 模式 → 只提示,不做任何切换(print/json 模式下 ctx.ui 是 noOpUIContext,
|
|
17
|
+
* setTheme 恒返回 {success:false},没必要静默失败)
|
|
18
|
+
*
|
|
19
|
+
* 预览的两种呈现(都在同一个选择器里):
|
|
20
|
+
* 1. **整屏换色**:setThemeInstance 会 ui.invalidate() + 重绘,transcript / 编辑器 / statusline
|
|
21
|
+
* 全部跟着换 —— 这是最真实的预览。选择器自身的 SelectList 配色用的是 `getSelectListTheme()`,
|
|
22
|
+
* 它的回调闭包引用的是 pi 的全局 theme proxy(globalThis 共享),所以列表自己也实时换色。
|
|
23
|
+
* 2. **色板样本区**:用候选主题实例自己的 token 画 5 行样本(核心色 / markdown / 代码+diff+tool /
|
|
24
|
+
* 背景块 / thinking 档位),终端太矮看不出整屏差别时也能直接对比。
|
|
25
|
+
*
|
|
26
|
+
* 已知取舍(都是刻意选的,别"顺手优化"):
|
|
27
|
+
* - 取消时也用**对象路径**恢复原主题,而不是名字路径。名字路径会把 `settings.json` 里
|
|
28
|
+
* `theme: "light/dark"`(自动模式)或未设置(按终端探测)的情况**改写成固定名字**,
|
|
29
|
+
* 这是取消操作不该有的副作用。代价:`setThemeInstance()` 内部会 `stopThemeWatcher()`,
|
|
30
|
+
* 所以预览过又取消之后,本次进程内主题文件的热重载失效(重启 pi 或回车确认即恢复,
|
|
31
|
+
* 因为 `setTheme(name)` 会带 enableWatcher 重新起 watcher)。
|
|
32
|
+
* - 没预览过就取消(打开后直接 Esc)→ 什么都不做,不动主题也不动 watcher。
|
|
33
|
+
* - 色板/标题/边框一律**每次 render 现算**(见 LiveText),不把 theme 颜色预烘进缓存 ——
|
|
34
|
+
* tui.md「Invalidation and Theme Changes」:预烘的 ANSI 串在换主题后不会更新。
|
|
35
|
+
* - `DynamicBorder` 在 jiti 加载的扩展里必须显式传色函数(它的无参默认值依赖 pi 自己那份
|
|
36
|
+
* module cache 里的 theme,扩展侧可能是另一个 cache)。
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
40
|
+
import { DynamicBorder, getSelectListTheme } from "@earendil-works/pi-coding-agent";
|
|
41
|
+
import { Container, SelectList, truncateToWidth, type Component, type SelectItem } from "@earendil-works/pi-tui";
|
|
42
|
+
|
|
43
|
+
/** SelectList 一次最多显示几行(色板还要占 5 行,别让选择器顶满屏) */
|
|
44
|
+
const MAX_VISIBLE = 8;
|
|
45
|
+
|
|
46
|
+
type PickerResult = { action: "apply"; name: string } | { action: "cancel"; previewed: boolean };
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 每次 render 现算文本行的最小组件。
|
|
50
|
+
*
|
|
51
|
+
* pi 的 `Text` 会把内容(含 ANSI)缓存起来,换主题后 `invalidate()` 只清缓存、不会重新着色,
|
|
52
|
+
* 所以预烘过颜色的文本会留着旧主题的颜色。这里干脆不缓存:每帧用当前 theme 重新拼。
|
|
53
|
+
*/
|
|
54
|
+
class LiveText implements Component {
|
|
55
|
+
constructor(private readonly build: (width: number) => string[]) {}
|
|
56
|
+
|
|
57
|
+
render(width: number): string[] {
|
|
58
|
+
return this.build(width);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
invalidate(): void {
|
|
62
|
+
// 无缓存,无需清理
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 色板样本区:用候选主题自己的 token 画几行,覆盖 pi 里最常见的着色场景 */
|
|
67
|
+
function swatchLines(theme: Theme, width: number): string[] {
|
|
68
|
+
const row = (...parts: string[]) => truncateToWidth(` ${parts.join(" ")}`, width);
|
|
69
|
+
|
|
70
|
+
return [
|
|
71
|
+
// 核心色
|
|
72
|
+
row(
|
|
73
|
+
theme.fg("accent", "accent"),
|
|
74
|
+
theme.fg("success", "success"),
|
|
75
|
+
theme.fg("warning", "warning"),
|
|
76
|
+
theme.fg("error", "error"),
|
|
77
|
+
theme.fg("muted", "muted"),
|
|
78
|
+
theme.fg("dim", "dim"),
|
|
79
|
+
),
|
|
80
|
+
// markdown
|
|
81
|
+
row(
|
|
82
|
+
theme.fg("mdHeading", "# Heading"),
|
|
83
|
+
theme.fg("mdCode", "`code`"),
|
|
84
|
+
`${theme.fg("mdLink", "[link]")}${theme.fg("mdLinkUrl", "(url)")}`,
|
|
85
|
+
`${theme.fg("mdListBullet", "•")} item`,
|
|
86
|
+
theme.fg("mdQuote", "> quote"),
|
|
87
|
+
),
|
|
88
|
+
// 语法高亮 + diff + 工具名
|
|
89
|
+
row(
|
|
90
|
+
`${theme.fg("syntaxKeyword", "function ")}${theme.fg("syntaxFunction", "name")}${theme.fg("syntaxPunctuation", "(")}${theme.fg("syntaxString", '"str"')}${theme.fg("syntaxPunctuation", ")")} ${theme.fg("syntaxComment", "// note")}`,
|
|
91
|
+
theme.fg("toolDiffAdded", "+added"),
|
|
92
|
+
theme.fg("toolDiffRemoved", "-removed"),
|
|
93
|
+
theme.fg("toolTitle", "tool"),
|
|
94
|
+
),
|
|
95
|
+
// 背景块(bg token 只有这几个,全列出来)
|
|
96
|
+
row(
|
|
97
|
+
theme.bg("userMessageBg", theme.fg("userMessageText", " user ")),
|
|
98
|
+
theme.bg("toolPendingBg", theme.fg("muted", " pending ")),
|
|
99
|
+
theme.bg("toolSuccessBg", theme.fg("toolOutput", " ok ")),
|
|
100
|
+
theme.bg("toolErrorBg", theme.fg("error", " err ")),
|
|
101
|
+
theme.bg("selectedBg", theme.fg("text", " selected ")),
|
|
102
|
+
),
|
|
103
|
+
// thinking 档位(编辑器边框色)
|
|
104
|
+
row(
|
|
105
|
+
theme.fg("thinkingOff", "off"),
|
|
106
|
+
theme.fg("thinkingMinimal", "minimal"),
|
|
107
|
+
theme.fg("thinkingLow", "low"),
|
|
108
|
+
theme.fg("thinkingMedium", "medium"),
|
|
109
|
+
theme.fg("thinkingHigh", "high"),
|
|
110
|
+
theme.fg("thinkingXhigh", "xhigh"),
|
|
111
|
+
theme.fg("thinkingMax", "max"),
|
|
112
|
+
),
|
|
113
|
+
];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 打开选择器;返回用户的选择(undefined = 组件没能打开) */
|
|
117
|
+
async function pickTheme(ctx: ExtensionContext): Promise<PickerResult | undefined> {
|
|
118
|
+
const themes = ctx.ui.getAllThemes();
|
|
119
|
+
const currentName = ctx.ui.theme?.name;
|
|
120
|
+
const items: SelectItem[] = themes.map((t) => ({
|
|
121
|
+
value: t.name,
|
|
122
|
+
label: t.name,
|
|
123
|
+
description: t.name === currentName ? "(current)" : undefined,
|
|
124
|
+
}));
|
|
125
|
+
|
|
126
|
+
return ctx.ui.custom<PickerResult>((tui, theme, _keybindings, done) => {
|
|
127
|
+
const container = new Container();
|
|
128
|
+
|
|
129
|
+
// 预览状态:色板画哪个主题、以及有没有真的动过全局主题(决定取消时要不要恢复)
|
|
130
|
+
let previewTheme: Theme = ctx.ui.getTheme(currentName ?? "") ?? theme;
|
|
131
|
+
let previewed = false;
|
|
132
|
+
|
|
133
|
+
container.addChild(new DynamicBorder((s) => theme.fg("border", s)));
|
|
134
|
+
container.addChild(
|
|
135
|
+
new LiveText((width) => [
|
|
136
|
+
truncateToWidth(
|
|
137
|
+
` ${theme.bold(theme.fg("accent", "选择主题"))} ${theme.fg("dim", "↑↓ 预览 · enter 应用 · esc 取消")}`,
|
|
138
|
+
width,
|
|
139
|
+
),
|
|
140
|
+
]),
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const selectList = new SelectList(items, Math.min(items.length, MAX_VISIBLE), getSelectListTheme(), {
|
|
144
|
+
minPrimaryColumnWidth: 12,
|
|
145
|
+
maxPrimaryColumnWidth: 32,
|
|
146
|
+
});
|
|
147
|
+
const currentIndex = themes.findIndex((t) => t.name === currentName);
|
|
148
|
+
if (currentIndex !== -1) selectList.setSelectedIndex(currentIndex);
|
|
149
|
+
|
|
150
|
+
selectList.onSelectionChange = (item) => {
|
|
151
|
+
// 对象路径 = 内存预览,不写 settings.json
|
|
152
|
+
const instance = ctx.ui.getTheme(item.value);
|
|
153
|
+
if (!instance) return; // 名字在列表里但加载失败:保持当前主题不动
|
|
154
|
+
previewTheme = instance;
|
|
155
|
+
previewed = true;
|
|
156
|
+
ctx.ui.setTheme(instance);
|
|
157
|
+
tui.requestRender();
|
|
158
|
+
};
|
|
159
|
+
selectList.onSelect = (item) => done({ action: "apply", name: item.value });
|
|
160
|
+
selectList.onCancel = () => done({ action: "cancel", previewed });
|
|
161
|
+
|
|
162
|
+
container.addChild(selectList);
|
|
163
|
+
container.addChild(new LiveText((width) => swatchLines(previewTheme, width)));
|
|
164
|
+
container.addChild(
|
|
165
|
+
new LiveText((width) => [
|
|
166
|
+
truncateToWidth(` ${theme.fg("dim", `共 ${themes.length} 个主题 · 当前 ${currentName ?? "未知"}`)}`, width),
|
|
167
|
+
]),
|
|
168
|
+
);
|
|
169
|
+
container.addChild(new DynamicBorder((s) => theme.fg("border", s)));
|
|
170
|
+
|
|
171
|
+
// Container 不带 handleInput,键盘要自己转发给 SelectList;鼠标交给 Container 分发
|
|
172
|
+
// (SelectList 自带 handleMouse,点击同样会触发预览/选择)
|
|
173
|
+
return {
|
|
174
|
+
render: (width: number) => container.render(width),
|
|
175
|
+
invalidate: () => container.invalidate(),
|
|
176
|
+
handleInput: (data: string) => {
|
|
177
|
+
selectList.handleInput(data);
|
|
178
|
+
tui.requestRender();
|
|
179
|
+
},
|
|
180
|
+
handleMouse: (event) => container.handleMouse(event),
|
|
181
|
+
};
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export default function (pi: ExtensionAPI) {
|
|
186
|
+
// 给 `/theme <TAB>` 用的主题名缓存:getArgumentCompletions 没有 ctx,只能靠这里。
|
|
187
|
+
// session_start 会在新建 / resume / fork 时刷新;注意 /reload 重载扩展后缓存会清空,
|
|
188
|
+
// 要等下一次 session_start 或手动跑一次 /theme 才重新有补全。
|
|
189
|
+
let themeNames: string[] = [];
|
|
190
|
+
|
|
191
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
192
|
+
if (!ctx.hasUI) return;
|
|
193
|
+
themeNames = ctx.ui.getAllThemes().map((t) => t.name);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
pi.registerCommand("theme", {
|
|
197
|
+
description: "Switch pi theme (live preview while browsing)",
|
|
198
|
+
getArgumentCompletions: (prefix: string) => {
|
|
199
|
+
// getArgumentCompletions 只有 prefix、没有 ctx,拿不到 ctx.ui.getAllThemes(),
|
|
200
|
+
// 所以用 session_start / 上次调用时缓存下来的名字(见下方 themeNames)。
|
|
201
|
+
// 缓存为空(例如刚 /reload 还没开新会话)就返回 null,交给内置补全,不瞎猜。
|
|
202
|
+
const items = themeNames
|
|
203
|
+
.filter((name) => name.startsWith(prefix))
|
|
204
|
+
.map((name) => ({ value: name, label: name }));
|
|
205
|
+
return items.length > 0 ? items : null;
|
|
206
|
+
},
|
|
207
|
+
handler: async (args, ctx) => {
|
|
208
|
+
if (!ctx.hasUI) {
|
|
209
|
+
ctx.ui.notify("/theme 需要在交互模式下使用", "warning");
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const themes = ctx.ui.getAllThemes();
|
|
214
|
+
themeNames = themes.map((t) => t.name);
|
|
215
|
+
if (themes.length === 0) {
|
|
216
|
+
ctx.ui.notify("没有可用主题(是否用了 --no-themes?)", "warning");
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const arg = args.trim();
|
|
221
|
+
if (arg) {
|
|
222
|
+
// 直接切换:先精确匹配,再忽略大小写兜底
|
|
223
|
+
const match = themes.find((t) => t.name === arg) ?? themes.find((t) => t.name.toLowerCase() === arg.toLowerCase());
|
|
224
|
+
if (!match) {
|
|
225
|
+
ctx.ui.notify(`未知主题 "${arg}",可用:${themes.map((t) => t.name).join(" / ")}`, "error");
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
applyTheme(ctx, match.name);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (ctx.mode !== "tui") {
|
|
233
|
+
ctx.ui.notify(`非 TUI 模式不能弹选择器,用 /theme <名字>:${themes.map((t) => t.name).join(" / ")}`, "warning");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const originalName = ctx.ui.theme?.name;
|
|
238
|
+
const result = await pickTheme(ctx);
|
|
239
|
+
if (!result) return;
|
|
240
|
+
|
|
241
|
+
if (result.action === "cancel") {
|
|
242
|
+
if (!result.previewed) return; // 没动过主题,无需恢复
|
|
243
|
+
// 对象路径恢复:settings.json 全程没写过,所以原样不动(代价见文件头注释)
|
|
244
|
+
const restore = originalName ? ctx.ui.getTheme(originalName) : undefined;
|
|
245
|
+
if (restore) ctx.ui.setTheme(restore);
|
|
246
|
+
else ctx.ui.notify(`已取消,但无法恢复原主题(${originalName ?? "未知"}),请用 /theme 重选`, "warning");
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
applyTheme(ctx, result.name);
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** 名字路径 = 应用 + 写 settings.json(唯一会落盘的入口) */
|
|
256
|
+
function applyTheme(ctx: ExtensionContext, name: string): void {
|
|
257
|
+
const result = ctx.ui.setTheme(name);
|
|
258
|
+
if (!result.success) {
|
|
259
|
+
ctx.ui.notify(`切换到 ${name} 失败:${result.error}`, "error");
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
ctx.ui.notify(`已切换到 ${name}`, "info");
|
|
263
|
+
}
|