@foolsecret/pi-prompt 0.4.0 → 0.4.8

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/src/draft.ts ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * 配置草稿层(draft):抽屉内所有编辑先改**内存**,Ctrl+S 时统一落盘。
3
+ *
4
+ * 为什么需要这一层(对齐姊妹项目 pi-pricer 的 PricingDraft):
5
+ * - 用户要"先改内存、Ctrl+S 保存"的可撤销中间态;
6
+ * - 保存前可以整体丢弃(Ctrl+R),误改不会立刻落盘;
7
+ * - 纯逻辑无 TUI 依赖,可直接单测。
8
+ *
9
+ * 与 pi-pricer 的差异:pi-pricer 保存前要跑引用完整性校验(删价格可能被方案引用),
10
+ * pi-prompt 的配置是扁平 JSON、无跨字段引用,故校验只需"值域合法"。
11
+ *
12
+ * ⚠️ 会话三轴(show/write/do)不在配置文件里,而是落会话条目(prompt-axes)。
13
+ * save() 必须**两处都落**,否则"未保存"语义是假的(轴改了却没保存)。
14
+ */
15
+
16
+ import type { PromptConfigFile, ToolOutputConfig } from "./config.ts";
17
+ import type { DoMode, ShowMode, WriteMode } from "./modes.ts";
18
+
19
+ /** 三轴取值(会话档) */
20
+ export interface SessionAxes {
21
+ show: ShowMode;
22
+ write: WriteMode;
23
+ do: DoMode;
24
+ }
25
+
26
+ /** 保存结果:ok=false 时 reason 说明原因 */
27
+ export interface DraftSaveResult {
28
+ ok: boolean;
29
+ reason: string;
30
+ }
31
+
32
+ /** 草稿层的落盘接口(由接线层注入,避免 draft 直接依赖 fs / session) */
33
+ export interface DraftPersist {
34
+ /** 写入配置文件的已知字段(合并写,保留未展示字段) */
35
+ writeConfig(patch: Partial<PromptConfigFile>): boolean;
36
+ /** 写入会话三轴(落会话条目) */
37
+ writeSessionAxes(axes: SessionAxes): void;
38
+ }
39
+
40
+ /** 变更面(状态栏展示用;中文名与抽屉分类一致) */
41
+ export type ChangeArea = "风格" | "代码" | "行动" | "工具" | "上下文" | "其他";
42
+
43
+ /** 数值参数名(与 config 的 writeNumber 一致) */
44
+ export type NumberName =
45
+ | "maxTokensCap"
46
+ | "autoSampleInterval"
47
+ | "autoSampleMaxPerDay"
48
+ | "hysteresis"
49
+ | "autoCompactMaxTokens"
50
+ | "autoCompactPercent"
51
+ | "autoCompactMinTurns";
52
+
53
+ /** 布尔开关名(toolOutput 是嵌套的,单独处理) */
54
+ export type FlagName = "peakUpgrade" | "quietStartup" | "hideStatus" | "autoSample" | "autoCompact";
55
+
56
+ /** 工具名 */
57
+ export type ToolName = "grep" | "read" | "bash" | "default";
58
+
59
+ /**
60
+ * 编辑会话:持有配置的深拷贝 + 会话三轴暂存 + 变更记账。
61
+ * 所有 set* 方法只改内存;save() 才写盘;reset() 从磁盘重读。
62
+ */
63
+ export class PromptDraft {
64
+ /** 内存态配置(深拷贝,与磁盘解耦) */
65
+ private config: PromptConfigFile;
66
+ /** 内存态会话三轴 */
67
+ private axes: SessionAxes;
68
+ /** 自加载以来变更过的面 */
69
+ private changed = new Set<ChangeArea>();
70
+
71
+ constructor(
72
+ initialConfig: PromptConfigFile,
73
+ initialAxes: SessionAxes,
74
+ private readonly persist: DraftPersist,
75
+ ) {
76
+ this.config = structuredClone(initialConfig);
77
+ this.axes = { ...initialAxes };
78
+ }
79
+
80
+ /**
81
+ * 当前内存态(只读用途:渲染)。
82
+ * 返回**防御性拷贝**:否则调用方(readState/测试)拿到内部引用后一改
83
+ * 就会静默篡改草稿状态。
84
+ */
85
+ snapshot(): { config: PromptConfigFile; axes: SessionAxes } {
86
+ return { config: structuredClone(this.config), axes: { ...this.axes } };
87
+ }
88
+
89
+ /** 是否有未保存改动 */
90
+ get isDirty(): boolean {
91
+ return this.changed.size > 0;
92
+ }
93
+
94
+ /** 未保存改动涉及的面(状态栏展示) */
95
+ get changedAreas(): ChangeArea[] {
96
+ return [...this.changed].sort();
97
+ }
98
+
99
+ private mark(area: ChangeArea): void {
100
+ this.changed.add(area);
101
+ }
102
+
103
+ // ── 会话三轴 ─────────────────────────────────────────────────────────
104
+
105
+ /** 设置会话档(show/write/do) */
106
+ setAxis(axis: keyof SessionAxes, value: string): boolean {
107
+ this.axes = { ...this.axes, [axis]: value as never };
108
+ this.mark(axis === "show" ? "风格" : axis === "write" ? "代码" : "行动");
109
+ return true;
110
+ }
111
+
112
+ // ── 默认档 ───────────────────────────────────────────────────────────
113
+
114
+ /** 设置新会话默认档(写配置字段) */
115
+ setDefaultAxis(axis: keyof SessionAxes, value: string): boolean {
116
+ const key = axis === "show" ? "defaultShow" : axis === "write" ? "defaultWrite" : "defaultDo";
117
+ this.config = { ...this.config, [key]: value };
118
+ this.mark(axis === "show" ? "风格" : axis === "write" ? "代码" : "行动");
119
+ return true;
120
+ }
121
+
122
+ // ── 开关 / 数值 / 工具上限 ───────────────────────────────────────────
123
+
124
+ /** 设置顶层布尔开关;toolOutput 是嵌套字段,走 setToolOutputEnabled */
125
+ setFlag(name: FlagName | "toolOutput", value: boolean): boolean {
126
+ if (name === "toolOutput") return this.setToolOutputEnabled(value);
127
+ this.config = { ...this.config, [name]: value };
128
+ this.mark(name === "autoCompact" ? "上下文" : name === "autoSample" ? "其他" : "其他");
129
+ return true;
130
+ }
131
+
132
+ /** 设置工具输出截断总开关(嵌套 toolOutput.enabled) */
133
+ setToolOutputEnabled(value: boolean): boolean {
134
+ this.config = { ...this.config, toolOutput: { ...this.config.toolOutput, enabled: value } };
135
+ this.mark("工具");
136
+ return true;
137
+ }
138
+
139
+ /** 设置数值参数(校验:非负;百分比必须在 0~1) */
140
+ setNumber(name: NumberName, value: number): boolean {
141
+ if (!Number.isFinite(value) || value < 0) return false;
142
+ if (name === "autoCompactPercent" && value >= 1) return false;
143
+ this.config = { ...this.config, [name]: value };
144
+ this.mark(name.startsWith("autoCompact") ? "上下文" : name === "maxTokensCap" ? "工具" : "其他");
145
+ return true;
146
+ }
147
+
148
+ /** 设置某工具的输出上限(0 = 不截) */
149
+ setToolCap(tool: ToolName, value: number): boolean {
150
+ if (!Number.isFinite(value) || value < 0) return false;
151
+ const toolOutput: ToolOutputConfig = { ...this.config.toolOutput, [tool]: value };
152
+ this.config = { ...this.config, toolOutput };
153
+ this.mark("工具");
154
+ return true;
155
+ }
156
+
157
+ // ── 提交 / 放弃 ──────────────────────────────────────────────────────
158
+
159
+ /**
160
+ * 落盘:配置字段 + 会话三轴**都要写**(见文件头 ⚠️)。
161
+ * 无改动时直接返回成功(幂等,Ctrl+S 空按不报错)。
162
+ */
163
+ save(): DraftSaveResult {
164
+ if (!this.isDirty) return { ok: true, reason: "" };
165
+ const ok = this.persist.writeConfig(this.config);
166
+ if (!ok) return { ok: false, reason: "配置写入失败" };
167
+ this.persist.writeSessionAxes({ ...this.axes });
168
+ this.changed.clear();
169
+ return { ok: true, reason: "" };
170
+ }
171
+
172
+ /** 丢弃所有未保存改动(回到构造时的初值 = 磁盘态) */
173
+ reset(initialConfig: PromptConfigFile, initialAxes: SessionAxes): void {
174
+ this.config = structuredClone(initialConfig);
175
+ this.axes = { ...initialAxes };
176
+ this.changed.clear();
177
+ }
178
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * 只读信息页:把结构化说明以 **Markdown 排版**渲染,自带固定高度滚动窗口。
3
+ * (移植自姊妹项目 pi-pricer 的 pricing-info-page.ts,行为对齐。)
4
+ *
5
+ * 为什么需要它:`ctx.ui.notify` 只接受纯文本,长内容既无样式层级也不可滚动,
6
+ * 而 `##` 这类 Markdown 语法会**原样显示**。本组件统一成
7
+ * "Markdown 内容 + 自滚动窗口"一条通路。
8
+ *
9
+ * ⚠ 标题显示为字面 `##` 的根因:给 `Markdown` 传了内容但**没传 MarkdownTheme**
10
+ * (或 theme 缺 heading)时标题不会套样式。故 buildMarkdownTheme 必须把
11
+ * `heading` 映射到 `theme.fg("mdHeading", bold(text))`,其余标记同理。
12
+ */
13
+
14
+ import { getKeybindings, Markdown, type Component, type MarkdownTheme } from "@earendil-works/pi-tui";
15
+
16
+ /** 信息页固定显示的最大行数(跟随 SettingsList 自限高惯例,不侵入布局系统) */
17
+ export const MAX_INFO_LINES = 16;
18
+
19
+ /** 抽屉主题(fg/bold 的最小依赖面;由 pi 的 ctx.ui.custom factory 注入真实色板) */
20
+ export interface DrawerTheme {
21
+ fg: (color: string, text: string) => string;
22
+ bold: (text: string) => string;
23
+ }
24
+
25
+ /**
26
+ * 把 DrawerTheme 映射成 pi-tui MarkdownTheme。
27
+ * 标题用 mdHeading、代码用 mdCode / mdCodeBlock,正文继承默认样式。
28
+ */
29
+ export function buildMarkdownTheme(theme: DrawerTheme): MarkdownTheme {
30
+ return {
31
+ heading: (text: string) => theme.fg("mdHeading", theme.bold(text)),
32
+ link: (text: string) => theme.fg("mdLink", text),
33
+ linkUrl: (text: string) => theme.fg("mdLinkUrl", text),
34
+ code: (text: string) => theme.fg("mdCode", text),
35
+ codeBlock: (text: string) => theme.fg("mdCodeBlock", text),
36
+ codeBlockBorder: (text: string) => theme.fg("mdCodeBlockBorder", text),
37
+ quote: (text: string) => theme.fg("mdQuote", text),
38
+ quoteBorder: (text: string) => theme.fg("mdQuoteBorder", text),
39
+ hr: (text: string) => theme.fg("mdHr", text),
40
+ listBullet: (text: string) => theme.fg("mdListBullet", text),
41
+ bold: (text: string) => theme.bold(text),
42
+ italic: (text: string) => theme.fg("muted", text),
43
+ strikethrough: (text: string) => theme.fg("dim", text),
44
+ underline: (text: string) => text,
45
+ };
46
+ }
47
+
48
+ /** 只读信息页:Markdown 渲染 + 固定高度窗口 + 内部滚动;Esc 走 goBack */
49
+ export class InfoPage implements Component {
50
+ /** 已渲染的完整内容行缓存(Markdown 按宽度折行,宽变才重渲) */
51
+ private cache: { width: number; lines: string[] } | null = null;
52
+ private scrollTop = 0;
53
+ private readonly markdown: Markdown;
54
+
55
+ constructor(
56
+ content: string,
57
+ private readonly theme: DrawerTheme,
58
+ private readonly goBack: () => void,
59
+ /** 页脚注(dim 渲染在提示条下方,说明本页关键上下文) */
60
+ private readonly notes: string[] = [],
61
+ ) {
62
+ this.markdown = new Markdown(content, 1, 0, buildMarkdownTheme(theme));
63
+ }
64
+
65
+ /** 取完整内容行(按 width 缓存) */
66
+ private allLines(width: number): string[] {
67
+ if (this.cache?.width === width) return this.cache.lines;
68
+ const lines = this.markdown.render(width);
69
+ this.cache = { width, lines };
70
+ return lines;
71
+ }
72
+
73
+ /** 内容总行数(用于滚动边界) */
74
+ private maxTop(): number {
75
+ const total = this.cache ? this.cache.lines.length : 0;
76
+ return Math.max(0, total - MAX_INFO_LINES);
77
+ }
78
+
79
+ render(width: number): string[] {
80
+ const lines = this.allLines(width);
81
+ if (this.scrollTop > this.maxTop()) this.scrollTop = this.maxTop();
82
+ const window = lines.slice(this.scrollTop, this.scrollTop + MAX_INFO_LINES);
83
+ const hint = this.theme.fg("dim", " ↑↓ 滚动 · PgUp/PgDn 翻页 · Esc 返回");
84
+ const out = [...window, hint];
85
+ for (const n of this.notes) out.push(this.theme.fg("dim", ` ${n}`));
86
+ return out;
87
+ }
88
+
89
+ handleInput(data: string): void {
90
+ const kb = getKeybindings();
91
+ if (kb.matches(data, "tui.select.cancel")) {
92
+ this.goBack();
93
+ return;
94
+ }
95
+ const maxTop = this.maxTop();
96
+ if (kb.matches(data, "tui.select.up")) {
97
+ this.scrollTop = Math.max(0, this.scrollTop - 1);
98
+ } else if (kb.matches(data, "tui.select.down")) {
99
+ this.scrollTop = Math.min(maxTop, this.scrollTop + 1);
100
+ } else if (kb.matches(data, "tui.select.pageUp") || kb.matches(data, "tui.altScreen.pageUp") || kb.matches(data, "tui.editor.pageUp")) {
101
+ this.scrollTop = Math.max(0, this.scrollTop - MAX_INFO_LINES);
102
+ } else if (kb.matches(data, "tui.select.pageDown") || kb.matches(data, "tui.altScreen.pageDown") || kb.matches(data, "tui.editor.pageDown")) {
103
+ this.scrollTop = Math.min(maxTop, this.scrollTop + MAX_INFO_LINES);
104
+ } else if (kb.matches(data, "tui.altScreen.top") || kb.matches(data, "tui.editor.cursorLineStart")) {
105
+ this.scrollTop = 0;
106
+ } else if (kb.matches(data, "tui.altScreen.bottom") || kb.matches(data, "tui.editor.cursorLineEnd")) {
107
+ this.scrollTop = maxTop;
108
+ }
109
+ }
110
+
111
+ invalidate(): void {
112
+ this.cache = null;
113
+ this.markdown.invalidate();
114
+ }
115
+ }