pi-cc-loop 0.4.0 → 0.6.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/README.md +37 -9
- package/config.ts +55 -16
- package/index.ts +16 -63
- package/package.json +1 -1
- package/settings.ts +314 -0
package/README.md
CHANGED
|
@@ -37,8 +37,8 @@ ln -sfn "$PWD" ~/.pi/agent/extensions/pi-cc-loop
|
|
|
37
37
|
| 过期 | 周期任务 7 天后最后执行一次再删除;一次性任务不过期 | 自动 |
|
|
38
38
|
| 次数上限 | `maxFires`,到顶自动删除 | `--max N` |
|
|
39
39
|
| 持久化 | `durable: true` 写入 `.pi/scheduled_tasks.json`,重启后继续;同一项目只由一个 Pi 会话执行 | `--durable` |
|
|
40
|
-
| 底部摘要 | Pi 底部状态栏一行:`loop 2 · next 3m`,无任务时不占位 | `/
|
|
41
|
-
| 界面设置 |
|
|
40
|
+
| 底部摘要 | Pi 底部状态栏一行:`loop 2 · next 3m`,无任务时不占位 | `/pi_cc footer on\|off` |
|
|
41
|
+
| 界面设置 | 和 Pi 的 `/settings` 一样的面板,本地命令,不进对话 | `/pi_cc` |
|
|
42
42
|
|
|
43
43
|
## 命令
|
|
44
44
|
|
|
@@ -48,12 +48,12 @@ ln -sfn "$PWD" ~/.pi/agent/extensions/pi-cc-loop
|
|
|
48
48
|
/loop [间隔] 跑 loop.md,没有就跑内置维护 prompt
|
|
49
49
|
/loop status 列出当前任务
|
|
50
50
|
/loop stop [id] 停止一个或全部
|
|
51
|
-
/loop footer on|off 本地:底部摘要
|
|
52
|
-
/loop settings ... 本地:界面设置
|
|
53
51
|
/loop help 本地:帮助
|
|
52
|
+
/pi_cc 本地:pi-cc 设置面板(见下)
|
|
54
53
|
```
|
|
55
54
|
|
|
56
|
-
前五个会作为普通 user
|
|
55
|
+
前五个会作为普通 user 消息进入对话(内容就是你输入的那行),模型再按技能说明调用工具;后两个只改界面,不进对话。
|
|
56
|
+
旧的 `/loop footer`、`/loop settings` 会提示改用 `/pi_cc`,不会当成 loop 请求。
|
|
57
57
|
|
|
58
58
|
间隔写法没有约束,模型自己解析:`30s`、`5m`、`2h`、`1d`、`check the deploy every 20m`、
|
|
59
59
|
`每2分钟输出一次`、`每分钟打印一次hi` 都可以。小于一分钟会向上取整到每分钟。
|
|
@@ -86,16 +86,44 @@ Ctrl+O 展开后多一行元信息(任务 id、节奏)。`compactMessages`
|
|
|
86
86
|
|
|
87
87
|
## 设置
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
三个 pi-cc 扩展共用 Pi 配置目录下的 `pi-cc.json`(默认 `~/.pi/agent/pi-cc.json`,跟随 `PI_CODING_AGENT_DIR`),文件不存在时用默认值:
|
|
90
90
|
|
|
91
91
|
```jsonc
|
|
92
92
|
{
|
|
93
93
|
"compactMessages": true, // 触发块和 Monitor 事件用上面的样式;false 为旧的带底色卡片
|
|
94
|
-
"footer": true
|
|
94
|
+
"footer": true, // 底部摘要:loop 的一行和 Monitor 的 `monitor N`
|
|
95
|
+
"draft": true, // pi-cc-plugins:Ctrl+C 找回
|
|
96
|
+
"turnSummary": true // pi-cc-plugins:回合摘要一行
|
|
95
97
|
}
|
|
96
98
|
```
|
|
97
99
|
|
|
98
|
-
|
|
100
|
+
后两项只被 pi-cc-plugins 读取;没装它的会话里,面板中的这两项不生效,值原样保留。
|
|
101
|
+
|
|
102
|
+
在 TUI 里输入 `/pi_cc` 会打开设置面板,样子和操作都和 Pi 的 `/settings` 一样:上下键选择,Enter/空格切换,Esc 关闭;
|
|
103
|
+
改动立即保存,所有 pi-cc 扩展同时生效。Pi 的 `/settings` 不接受扩展加项,所以 pi-cc 的设置放在 `/pi_cc` 下。
|
|
104
|
+
|
|
105
|
+
`/pi_cc` 由三个 pi-cc 包(loop、monitor、plugins)共同提供:装了其中任意一个,命令就在;装了多个,也只有一个 `/pi_cc`
|
|
106
|
+
(Pi 会把不同扩展的同名命令变成 `/pi_cc:1`、`/pi_cc:2`,所以由最先加载的包认领注册,会话结束时释放,`/reload` 会重新认领)。
|
|
107
|
+
|
|
108
|
+
```text
|
|
109
|
+
─────────────────────────────────────────────────────────────
|
|
110
|
+
pi-cc settings · ~/.pi/agent/pi-cc.json
|
|
111
|
+
|
|
112
|
+
→ Message style compact
|
|
113
|
+
Footer summary on
|
|
114
|
+
|
|
115
|
+
How scheduled fires and Monitor events are drawn. compact: ✻ header
|
|
116
|
+
and indented body; card: the old padded card.
|
|
117
|
+
|
|
118
|
+
Enter/Space to change · Esc to cancel
|
|
119
|
+
─────────────────────────────────────────────────────────────
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
也可以用文字命令:`/pi_cc messages on|off`、`/pi_cc footer on|off`,适合脚本和 RPC;
|
|
123
|
+
非 TUI 模式下,不带参数的 `/pi_cc` 输出当前设置。命令只写改动的那一项,文件里的其他内容原样保留。
|
|
124
|
+
|
|
125
|
+
环境变量 `PI_CC_FOOTER`、`PI_CC_COMPACT_MESSAGES`(pi-cc-plugins 的两项是 `PI_CC_DRAFT`、`PI_CC_TURN_SUMMARY`)优先于文件,但不会被写进文件。设着的时候,面板里这一项显示实际生效的值、不能切换,
|
|
126
|
+
说明里写出是哪个变量;文字输出会在对应项后面标出来(如 `hidden (PI_CC_FOOTER=0)`);用文字命令切换会写进文件,但提示会说明变量还在覆盖它。
|
|
99
127
|
|
|
100
128
|
## 底部摘要
|
|
101
129
|
|
|
@@ -124,7 +152,7 @@ Ctrl+O 展开后多一行元信息(任务 id、节奏)。`compactMessages`
|
|
|
124
152
|
| `PI_LOOP_DURABLE_SYNC_MS` | `5000` | 持久化任务文件的同步间隔 |
|
|
125
153
|
| `PI_CC_FOOTER` | — | `0` / `1`,覆盖设置文件里的 `footer` |
|
|
126
154
|
| `PI_CC_COMPACT_MESSAGES` | — | `0` / `1`,覆盖设置文件里的 `compactMessages` |
|
|
127
|
-
| `PI_CC_CONFIG_FILE` | `~/.pi/agent/pi-cc.json` |
|
|
155
|
+
| `PI_CC_CONFIG_FILE` | `~/.pi/agent/pi-cc.json` | 设置文件路径;不设时在 Pi 的配置目录里,跟随 `PI_CODING_AGENT_DIR` |
|
|
128
156
|
|
|
129
157
|
## 兼容性
|
|
130
158
|
|
package/config.ts
CHANGED
|
@@ -1,24 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared settings for the pi-cc extensions.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* toggle changes the
|
|
6
|
-
* missing or malformed the defaults apply.
|
|
4
|
+
* pi-cc-loop, pi-cc-monitor and pi-cc-plugins read the same small JSON file
|
|
5
|
+
* so a single toggle changes the whole family at once. The file is optional;
|
|
6
|
+
* when it is missing or malformed the defaults apply. It sits in Pi's agent
|
|
7
|
+
* directory (PI_CODING_AGENT_DIR, default ~/.pi/agent), next to Pi's own
|
|
8
|
+
* settings.
|
|
7
9
|
*
|
|
8
10
|
* ~/.pi/agent/pi-cc.json
|
|
9
11
|
* {
|
|
10
12
|
* "compactMessages": true,
|
|
11
|
-
* "footer": true
|
|
13
|
+
* "footer": true,
|
|
14
|
+
* "draft": true,
|
|
15
|
+
* "turnSummary": true
|
|
12
16
|
* }
|
|
13
17
|
*
|
|
14
|
-
*
|
|
18
|
+
* compactMessages and footer drive loop and monitor; draft and turnSummary
|
|
19
|
+
* drive pi-cc-plugins. A package that is not installed simply ignores its
|
|
20
|
+
* keys, and they are kept as the user left them.
|
|
21
|
+
*
|
|
22
|
+
* Environment overrides (useful for tests and quick experiments) win over the
|
|
23
|
+
* file while they are set, and are never written to it:
|
|
15
24
|
* PI_CC_CONFIG_FILE=/path/to/pi-cc.json
|
|
16
25
|
* PI_CC_COMPACT_MESSAGES=0|1
|
|
17
26
|
* PI_CC_FOOTER=0|1
|
|
27
|
+
* PI_CC_DRAFT=0|1
|
|
28
|
+
* PI_CC_TURN_SUMMARY=0|1
|
|
18
29
|
*/
|
|
19
30
|
|
|
31
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
20
32
|
import fs from "node:fs";
|
|
21
|
-
import os from "node:os";
|
|
22
33
|
import path from "node:path";
|
|
23
34
|
|
|
24
35
|
export interface PiCcConfig {
|
|
@@ -29,11 +40,25 @@ export interface PiCcConfig {
|
|
|
29
40
|
* Shared so one switch silences both packages.
|
|
30
41
|
*/
|
|
31
42
|
footer: boolean;
|
|
43
|
+
/** pi-cc-plugins: Ctrl+C keeps what it clears, Up brings it back. */
|
|
44
|
+
draft: boolean;
|
|
45
|
+
/** pi-cc-plugins: one line closes every agent run. */
|
|
46
|
+
turnSummary: boolean;
|
|
32
47
|
}
|
|
33
48
|
|
|
34
49
|
export const DEFAULT_CONFIG: PiCcConfig = {
|
|
35
50
|
compactMessages: true,
|
|
36
51
|
footer: true,
|
|
52
|
+
draft: true,
|
|
53
|
+
turnSummary: true,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** The environment variable that overrides each setting. */
|
|
57
|
+
const ENV_OVERRIDES: Record<keyof PiCcConfig, string> = {
|
|
58
|
+
compactMessages: "PI_CC_COMPACT_MESSAGES",
|
|
59
|
+
footer: "PI_CC_FOOTER",
|
|
60
|
+
draft: "PI_CC_DRAFT",
|
|
61
|
+
turnSummary: "PI_CC_TURN_SUMMARY",
|
|
37
62
|
};
|
|
38
63
|
|
|
39
64
|
function envBool(name: string): boolean | undefined {
|
|
@@ -44,11 +69,16 @@ function envBool(name: string): boolean | undefined {
|
|
|
44
69
|
return undefined;
|
|
45
70
|
}
|
|
46
71
|
|
|
47
|
-
/** The
|
|
72
|
+
/** The variable overriding a setting right now, as `PI_CC_FOOTER=0`; undefined when none is. */
|
|
73
|
+
export function envOverride(key: keyof PiCcConfig): string | undefined {
|
|
74
|
+
const name = ENV_OVERRIDES[key];
|
|
75
|
+
return envBool(name) === undefined ? undefined : `${name}=${process.env[name]}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The settings file shared by pi-cc-loop and pi-cc-monitor, in Pi's agent directory. */
|
|
48
79
|
export function configPath(): string {
|
|
49
80
|
if (process.env.PI_CC_CONFIG_FILE) return process.env.PI_CC_CONFIG_FILE;
|
|
50
|
-
|
|
51
|
-
return path.join(agentDir, "pi-cc.json");
|
|
81
|
+
return path.join(getAgentDir(), "pi-cc.json");
|
|
52
82
|
}
|
|
53
83
|
|
|
54
84
|
function readConfig(): PiCcConfig {
|
|
@@ -61,10 +91,14 @@ function readConfig(): PiCcConfig {
|
|
|
61
91
|
}
|
|
62
92
|
return {
|
|
63
93
|
compactMessages:
|
|
64
|
-
envBool(
|
|
94
|
+
envBool(ENV_OVERRIDES.compactMessages) ??
|
|
65
95
|
(typeof file.compactMessages === "boolean" ? file.compactMessages : DEFAULT_CONFIG.compactMessages),
|
|
66
96
|
footer:
|
|
67
|
-
envBool(
|
|
97
|
+
envBool(ENV_OVERRIDES.footer) ?? (typeof file.footer === "boolean" ? file.footer : DEFAULT_CONFIG.footer),
|
|
98
|
+
draft: envBool(ENV_OVERRIDES.draft) ?? (typeof file.draft === "boolean" ? file.draft : DEFAULT_CONFIG.draft),
|
|
99
|
+
turnSummary:
|
|
100
|
+
envBool(ENV_OVERRIDES.turnSummary) ??
|
|
101
|
+
(typeof file.turnSummary === "boolean" ? file.turnSummary : DEFAULT_CONFIG.turnSummary),
|
|
68
102
|
};
|
|
69
103
|
}
|
|
70
104
|
|
|
@@ -81,9 +115,14 @@ export function invalidateConfig(): void {
|
|
|
81
115
|
cached = undefined;
|
|
82
116
|
}
|
|
83
117
|
|
|
84
|
-
/**
|
|
118
|
+
/**
|
|
119
|
+
* Write the changed keys to the settings file and return the settings now in
|
|
120
|
+
* effect. Only the patch is written: every other key stays as the user left
|
|
121
|
+
* it, and an environment override is never copied into the file. It still
|
|
122
|
+
* wins over the file while it is set, so the returned value can differ from
|
|
123
|
+
* the patch (see `envOverride`).
|
|
124
|
+
*/
|
|
85
125
|
export function saveConfig(patch: Partial<PiCcConfig>): PiCcConfig {
|
|
86
|
-
const next = { ...loadConfig(), ...patch };
|
|
87
126
|
const file = configPath();
|
|
88
127
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
89
128
|
// Keep keys this version does not know about: the file is the user's.
|
|
@@ -95,8 +134,8 @@ export function saveConfig(patch: Partial<PiCcConfig>): PiCcConfig {
|
|
|
95
134
|
// A missing or malformed file is rewritten from scratch.
|
|
96
135
|
}
|
|
97
136
|
const temp = `${file}.tmp`;
|
|
98
|
-
fs.writeFileSync(temp, `${JSON.stringify({ ...raw, ...
|
|
137
|
+
fs.writeFileSync(temp, `${JSON.stringify({ ...raw, ...patch }, null, 2)}\n`, "utf8");
|
|
99
138
|
fs.renameSync(temp, file);
|
|
100
|
-
cached =
|
|
101
|
-
return
|
|
139
|
+
cached = undefined;
|
|
140
|
+
return loadConfig();
|
|
102
141
|
}
|
package/index.ts
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
|
|
30
30
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
31
31
|
import { Type } from "typebox";
|
|
32
|
-
import {
|
|
32
|
+
import { invalidateConfig, loadConfig } from "./config.ts";
|
|
33
33
|
import {
|
|
34
34
|
humanSchedule,
|
|
35
35
|
isUnreachable,
|
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
loopCommandGuidelines,
|
|
58
58
|
} from "./prompts.ts";
|
|
59
59
|
import { formatClock, formatEta, renderScheduledMessage } from "./renderers.ts";
|
|
60
|
+
import { CONFIG_CHANNEL, registerPiCcCommand } from "./settings.ts";
|
|
60
61
|
import {
|
|
61
62
|
DEFAULT_TIMING,
|
|
62
63
|
generateId,
|
|
@@ -73,7 +74,6 @@ const STATE_ENTRY = "pi-cc-loop-state";
|
|
|
73
74
|
const MONITOR_EVENT_TYPE = "Monitor";
|
|
74
75
|
/** Footer status key, shared with every other extension's status line. */
|
|
75
76
|
const STATUS_KEY = "loop";
|
|
76
|
-
const CONFIG_CHANNEL = "pi-cc:config";
|
|
77
77
|
|
|
78
78
|
/**
|
|
79
79
|
* The event channel this extension publishes on when a chain ends. pi-cc-monitor
|
|
@@ -568,37 +568,15 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
568
568
|
const raw = args.trim();
|
|
569
569
|
const [head, ...rest] = raw.split(/\s+/);
|
|
570
570
|
|
|
571
|
-
// Settings
|
|
571
|
+
// Settings moved to /pi_cc: these words stay recognized so they cannot
|
|
572
|
+
// start a loop by mistake (everything else is a loop request).
|
|
572
573
|
switch ((head ?? "").toLowerCase()) {
|
|
573
574
|
case "footer":
|
|
574
|
-
case "statusline":
|
|
575
|
-
const arg = (rest[0] ?? "").toLowerCase();
|
|
576
|
-
if (!arg) {
|
|
577
|
-
notify(ctx, `Usage: /loop footer on|off (currently ${loadConfig().footer ? "on" : "off"})`, "info");
|
|
578
|
-
return;
|
|
579
|
-
}
|
|
580
|
-
setSetting(ctx, "footer", arg);
|
|
581
|
-
return;
|
|
582
|
-
}
|
|
575
|
+
case "statusline":
|
|
583
576
|
case "settings":
|
|
584
|
-
case "config":
|
|
585
|
-
|
|
586
|
-
if (!key) {
|
|
587
|
-
showSettings(ctx);
|
|
588
|
-
return;
|
|
589
|
-
}
|
|
590
|
-
const name = key.toLowerCase();
|
|
591
|
-
if (name === "messages" || name === "message") {
|
|
592
|
-
setSetting(ctx, "messages", value);
|
|
593
|
-
return;
|
|
594
|
-
}
|
|
595
|
-
if (name === "footer" || name === "status") {
|
|
596
|
-
setSetting(ctx, "footer", value);
|
|
597
|
-
return;
|
|
598
|
-
}
|
|
599
|
-
notify(ctx, "Usage: /loop settings [messages|footer] [on|off]", "info");
|
|
577
|
+
case "config":
|
|
578
|
+
notify(ctx, "Settings live under /pi_cc now: /pi_cc footer on|off, /pi_cc messages on|off", "info");
|
|
600
579
|
return;
|
|
601
|
-
}
|
|
602
580
|
case "help":
|
|
603
581
|
case "--help":
|
|
604
582
|
case "-h":
|
|
@@ -650,10 +628,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
650
628
|
"/loop [interval] <prompt> run <prompt> every <interval> (e.g. 30s, 5m, 2h, 1d)",
|
|
651
629
|
"/loop <prompt> self-paced: the agent picks the next delay",
|
|
652
630
|
"/loop [interval] the .pi/loop.md tasks, or the built-in maintenance prompt",
|
|
653
|
-
"/
|
|
654
|
-
"/loop settings show UI settings",
|
|
655
|
-
"/loop settings messages on|off compact two-line markers",
|
|
656
|
-
"/loop settings footer on|off the one-line footer summary",
|
|
631
|
+
"/pi_cc pi-cc settings panel (message style, footer summary)",
|
|
657
632
|
"",
|
|
658
633
|
"Esc on a self-paced loop's own turn cancels that loop; fixed-interval loops keep running.",
|
|
659
634
|
"Flags: --durable write the job to .pi/scheduled_tasks.json so it survives a restart.",
|
|
@@ -662,37 +637,15 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
662
637
|
notify(ctx, help, "info");
|
|
663
638
|
}
|
|
664
639
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
`pi-cc settings \u00b7 ${configPath()}`,
|
|
669
|
-
` messages: ${config.compactMessages ? "compact" : "card"}`,
|
|
670
|
-
` footer: ${config.footer ? "summary" : "hidden"}`,
|
|
671
|
-
"",
|
|
672
|
-
"Usage: /loop settings messages on|off",
|
|
673
|
-
" /loop settings footer on|off",
|
|
674
|
-
];
|
|
675
|
-
notify(ctx, lines.join("\n"), "info");
|
|
676
|
-
}
|
|
640
|
+
// The shared pi-cc settings and their /pi_cc command. The first pi-cc
|
|
641
|
+
// package to load registers it; this one re-claims its own claim on /reload.
|
|
642
|
+
registerPiCcCommand(pi, "pi-cc-loop");
|
|
677
643
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
showSettings(ctx);
|
|
684
|
-
return;
|
|
685
|
-
}
|
|
686
|
-
const patch: Partial<PiCcConfig> = key === "messages" ? { compactMessages: on } : { footer: on };
|
|
687
|
-
const next = saveConfig(patch);
|
|
688
|
-
try {
|
|
689
|
-
pi.events.emit(CONFIG_CHANNEL, next);
|
|
690
|
-
} catch {
|
|
691
|
-
// another extension must not break the command
|
|
692
|
-
}
|
|
693
|
-
notify(ctx, `${key === "messages" ? "Compact message style" : "Footer summary"} ${on ? "enabled" : "disabled"}`, "info");
|
|
694
|
-
if (key === "footer") refreshStatus();
|
|
695
|
-
}
|
|
644
|
+
// A setting changed (here or in another pi-cc package): the footer follows.
|
|
645
|
+
pi.events.on(CONFIG_CHANNEL, () => {
|
|
646
|
+
invalidateConfig();
|
|
647
|
+
refreshStatus();
|
|
648
|
+
});
|
|
696
649
|
|
|
697
650
|
// ------------------------------------------------------------------ tools
|
|
698
651
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-cc-loop",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Claude Code compatible /loop for the Pi coding agent: cron-scheduled fixed intervals, self-paced chains, and the same dispatch semantics as Claude Code.",
|
|
5
5
|
"author": "tarnish233",
|
|
6
6
|
"type": "module",
|
package/settings.ts
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The /pi_cc command: the settings every pi-cc package shares.
|
|
3
|
+
*
|
|
4
|
+
* This file is copied verbatim between pi-cc-loop, pi-cc-monitor and
|
|
5
|
+
* pi-cc-plugins (separate npm packages); each repo's tests/shared.test.mjs
|
|
6
|
+
* fails when the copies drift apart. The settings live in one pi-cc.json and
|
|
7
|
+
* apply to every pi-cc package installed. A change is announced on the
|
|
8
|
+
* "pi-cc:config" channel, so the packages repaint what the settings change
|
|
9
|
+
* (loop and monitor both redraw the footer).
|
|
10
|
+
*
|
|
11
|
+
* Pi's /settings cannot take items from an extension (its panel is a fixed
|
|
12
|
+
* list, and the command is handled before extension commands), so /pi_cc
|
|
13
|
+
* opens the same kind of screen, built from the same parts: SettingsList
|
|
14
|
+
* between two borders, the selected setting's description below it, and
|
|
15
|
+
* Enter/Space to change a value. A change is saved at once and Esc closes.
|
|
16
|
+
* A setting that an environment variable overrides shows the value in effect
|
|
17
|
+
* and cannot be changed here; its description names the variable. Outside the
|
|
18
|
+
* TUI, /pi_cc prints the summary.
|
|
19
|
+
*
|
|
20
|
+
* Only one /pi_cc exists however many pi-cc packages are installed: Pi turns
|
|
21
|
+
* same-named commands from different extensions into /pi_cc:1 and /pi_cc:2,
|
|
22
|
+
* and plain /pi_cc then matches neither. The first pi-cc package to load
|
|
23
|
+
* claims the command on a global symbol (extensions share one process), and
|
|
24
|
+
* releases the claim at session shutdown, so /reload and pi-config changes
|
|
25
|
+
* run the race again. The claim holds the owner's name, so a package
|
|
26
|
+
* re-loading over its own claim re-claims it. The API offers no other way:
|
|
27
|
+
* getCommands() cannot be called during loading.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { DynamicBorder, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
31
|
+
import { Container, SettingsList, Spacer, Text, type SettingItem, type SettingsListTheme } from "@earendil-works/pi-tui";
|
|
32
|
+
import os from "node:os";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
import { configPath, envOverride, loadConfig, saveConfig, type PiCcConfig } from "./config.ts";
|
|
35
|
+
|
|
36
|
+
/** Saved settings travel between the packages on this channel. */
|
|
37
|
+
export const CONFIG_CHANNEL = "pi-cc:config";
|
|
38
|
+
|
|
39
|
+
/** One /pi_cc per session: the first pi-cc package to load claims the name. */
|
|
40
|
+
const SLASH_CLAIM = Symbol.for("pi-cc:slash-command");
|
|
41
|
+
|
|
42
|
+
/** The part of Pi's theme the panel uses. */
|
|
43
|
+
export interface PanelTheme {
|
|
44
|
+
fg(color: string, text: string): string;
|
|
45
|
+
bold(text: string): string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface SettingSpec {
|
|
49
|
+
field: keyof PiCcConfig;
|
|
50
|
+
label: string;
|
|
51
|
+
/** Display values for true and false. */
|
|
52
|
+
on: string;
|
|
53
|
+
off: string;
|
|
54
|
+
description: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const SETTINGS: SettingSpec[] = [
|
|
58
|
+
{
|
|
59
|
+
field: "compactMessages",
|
|
60
|
+
label: "Message style",
|
|
61
|
+
on: "compact",
|
|
62
|
+
off: "card",
|
|
63
|
+
description:
|
|
64
|
+
"How scheduled fires and Monitor events are drawn. compact: \u273b header and indented body; card: the old padded card.",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
field: "footer",
|
|
68
|
+
label: "Footer summary",
|
|
69
|
+
on: "on",
|
|
70
|
+
off: "off",
|
|
71
|
+
description: "One line in Pi's footer while loops or monitors run: loop 2 \u00b7 next 3m, monitor 2.",
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
field: "draft",
|
|
75
|
+
label: "Draft recall",
|
|
76
|
+
on: "on",
|
|
77
|
+
off: "off",
|
|
78
|
+
description: "pi-cc-plugins: Ctrl+C keeps what it clears; Up on the empty editor brings it back.",
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
field: "turnSummary",
|
|
82
|
+
label: "Turn summary",
|
|
83
|
+
on: "on",
|
|
84
|
+
off: "off",
|
|
85
|
+
description: "pi-cc-plugins: one line closes every agent run \u2014 tools, duration, done time.",
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Pi's getSettingsListTheme(), built from the theme the panel is given: the
|
|
91
|
+
* global theme that function reads is not shared with extensions in every
|
|
92
|
+
* Pi runtime (see the note on Pi's DynamicBorder).
|
|
93
|
+
*/
|
|
94
|
+
function listTheme(theme: PanelTheme): SettingsListTheme {
|
|
95
|
+
return {
|
|
96
|
+
label: (text, selected) => (selected ? theme.fg("accent", text) : text),
|
|
97
|
+
value: (text, selected) => (selected ? theme.fg("accent", text) : theme.fg("muted", text)),
|
|
98
|
+
description: (text) => theme.fg("dim", text),
|
|
99
|
+
cursor: theme.fg("accent", "\u2192 "),
|
|
100
|
+
hint: (text) => theme.fg("dim", text),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A path with the home directory shortened to ~. */
|
|
105
|
+
function displayPath(file: string): string {
|
|
106
|
+
const home = os.homedir();
|
|
107
|
+
return file === home || file.startsWith(home + path.sep) ? `~${file.slice(home.length)}` : file;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function item(spec: SettingSpec): SettingItem {
|
|
111
|
+
const value = loadConfig()[spec.field] ? spec.on : spec.off;
|
|
112
|
+
const override = envOverride(spec.field);
|
|
113
|
+
if (override) {
|
|
114
|
+
// No values: Enter/Space leaves it alone, and the description says why.
|
|
115
|
+
return {
|
|
116
|
+
id: spec.field,
|
|
117
|
+
label: spec.label,
|
|
118
|
+
currentValue: value,
|
|
119
|
+
description: `${spec.description}\n${override} overrides this while set; unset it to change it here.`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return { id: spec.field, label: spec.label, currentValue: value, values: [spec.on, spec.off], description: spec.description };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface SettingsPanelOptions {
|
|
126
|
+
theme: PanelTheme;
|
|
127
|
+
/** Where the settings are saved, shown in the title. */
|
|
128
|
+
file: string;
|
|
129
|
+
/** Save one setting (and tell the other packages). */
|
|
130
|
+
onChange(field: keyof PiCcConfig, value: boolean): void;
|
|
131
|
+
onClose(): void;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface SettingsPanel {
|
|
135
|
+
render(width: number): string[];
|
|
136
|
+
invalidate(): void;
|
|
137
|
+
handleInput(data: string): void;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function settingsPanel(options: SettingsPanelOptions): SettingsPanel {
|
|
141
|
+
const { theme } = options;
|
|
142
|
+
const list = new SettingsList(
|
|
143
|
+
SETTINGS.map(item),
|
|
144
|
+
SETTINGS.length,
|
|
145
|
+
listTheme(theme),
|
|
146
|
+
(id, value) => {
|
|
147
|
+
const spec = SETTINGS.find((candidate) => candidate.field === id);
|
|
148
|
+
if (!spec) return;
|
|
149
|
+
options.onChange(spec.field, value === spec.on);
|
|
150
|
+
// Show what is in effect now.
|
|
151
|
+
list.updateValue(id, loadConfig()[spec.field] ? spec.on : spec.off);
|
|
152
|
+
},
|
|
153
|
+
() => options.onClose(),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const border = () => new DynamicBorder((text: string) => theme.fg("border", text));
|
|
157
|
+
const container = new Container();
|
|
158
|
+
container.addChild(border());
|
|
159
|
+
container.addChild(
|
|
160
|
+
new Text(`${theme.fg("accent", theme.bold("pi-cc settings"))}${theme.fg("dim", ` \u00b7 ${displayPath(options.file)}`)}`, 0, 0),
|
|
161
|
+
);
|
|
162
|
+
container.addChild(new Spacer(1));
|
|
163
|
+
container.addChild(list);
|
|
164
|
+
container.addChild(border());
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
render: (width) => container.render(width),
|
|
168
|
+
invalidate: () => container.invalidate(),
|
|
169
|
+
handleInput: (data) => list.handleInput(data),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
|
|
174
|
+
try {
|
|
175
|
+
if (ctx.hasUI) ctx.ui.notify(message, level);
|
|
176
|
+
} catch {
|
|
177
|
+
// a stale context must never break the command
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** ` (PI_CC_FOOTER=0)` when an environment variable decides the setting. */
|
|
182
|
+
function overrideNote(key: keyof PiCcConfig): string {
|
|
183
|
+
const override = envOverride(key);
|
|
184
|
+
return override ? ` (${override})` : "";
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function showSettings(ctx: ExtensionContext): void {
|
|
188
|
+
const config = loadConfig();
|
|
189
|
+
const lines = [
|
|
190
|
+
`pi-cc settings \u00b7 ${configPath()}`,
|
|
191
|
+
` messages: ${config.compactMessages ? "compact" : "card"}${overrideNote("compactMessages")}`,
|
|
192
|
+
` footer: ${config.footer ? "summary" : "hidden"}${overrideNote("footer")}`,
|
|
193
|
+
` draft: ${config.draft ? "on" : "off"}${overrideNote("draft")}`,
|
|
194
|
+
` summary: ${config.turnSummary ? "on" : "off"}${overrideNote("turnSummary")}`,
|
|
195
|
+
"",
|
|
196
|
+
"Usage: /pi_cc messages on|off",
|
|
197
|
+
" /pi_cc footer on|off",
|
|
198
|
+
" /pi_cc draft on|off",
|
|
199
|
+
" /pi_cc summary on|off",
|
|
200
|
+
];
|
|
201
|
+
notify(ctx, lines.join("\n"), "info");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Save one setting and tell every pi-cc package; each repaints itself. Returns the settings in effect. */
|
|
205
|
+
type ApplySetting = (field: keyof PiCcConfig, on: boolean) => PiCcConfig;
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* A bare /pi_cc in the TUI: a panel like Pi's /settings, which extensions
|
|
209
|
+
* cannot add to. Resolves when the panel closes; false when there is no TUI
|
|
210
|
+
* to show it in, so the caller prints the summary instead.
|
|
211
|
+
*/
|
|
212
|
+
async function openSettingsPanel(ctx: ExtensionCommandContext, apply: ApplySetting): Promise<boolean> {
|
|
213
|
+
if (ctx.mode !== "tui" || typeof ctx.ui.custom !== "function") return false;
|
|
214
|
+
await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
|
|
215
|
+
const panel = settingsPanel({
|
|
216
|
+
theme,
|
|
217
|
+
file: configPath(),
|
|
218
|
+
onChange: (field, value) => {
|
|
219
|
+
apply(field, value);
|
|
220
|
+
},
|
|
221
|
+
onClose: () => done(undefined),
|
|
222
|
+
});
|
|
223
|
+
return {
|
|
224
|
+
render: (width: number) => panel.render(width),
|
|
225
|
+
invalidate: () => panel.invalidate(),
|
|
226
|
+
handleInput: (data: string) => {
|
|
227
|
+
panel.handleInput(data);
|
|
228
|
+
tui.requestRender();
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
});
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The command word for each setting: the field it writes and its notice label. */
|
|
236
|
+
const KEYS: Record<string, { field: keyof PiCcConfig; label: string }> = {
|
|
237
|
+
messages: { field: "compactMessages", label: "Compact message style" },
|
|
238
|
+
message: { field: "compactMessages", label: "Compact message style" },
|
|
239
|
+
footer: { field: "footer", label: "Footer summary" },
|
|
240
|
+
status: { field: "footer", label: "Footer summary" },
|
|
241
|
+
statusline: { field: "footer", label: "Footer summary" },
|
|
242
|
+
draft: { field: "draft", label: "Draft recall" },
|
|
243
|
+
summary: { field: "turnSummary", label: "Turn summary" },
|
|
244
|
+
turnsummary: { field: "turnSummary", label: "Turn summary" },
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
function setSetting(ctx: ExtensionContext, apply: ApplySetting, key: string, raw: string | undefined): void {
|
|
248
|
+
const spec = KEYS[key];
|
|
249
|
+
if (!spec) {
|
|
250
|
+
showSettings(ctx);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const value = (raw ?? "").toLowerCase();
|
|
254
|
+
const on = value === "on" || value === "true" || value === "1";
|
|
255
|
+
const off = value === "off" || value === "false" || value === "0";
|
|
256
|
+
if (!on && !off) {
|
|
257
|
+
showSettings(ctx);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const field = spec.field;
|
|
261
|
+
const next = apply(field, on);
|
|
262
|
+
const override = envOverride(field);
|
|
263
|
+
if (override && next[field] !== on) {
|
|
264
|
+
// The file has the choice, but the variable decides while it is set.
|
|
265
|
+
notify(ctx, `${spec.label} saved as ${on ? "on" : "off"}, but ${override} overrides it while set`, "warning");
|
|
266
|
+
} else {
|
|
267
|
+
notify(ctx, `${spec.label} ${on ? "enabled" : "disabled"}`, "info");
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Register /pi_cc on behalf of one pi-cc package. The first package to load
|
|
273
|
+
* wins; the others leave the command to it, so there is never a /pi_cc:2.
|
|
274
|
+
*/
|
|
275
|
+
export function registerPiCcCommand(pi: ExtensionAPI, owner: string): void {
|
|
276
|
+
const claims = globalThis as typeof globalThis & Record<symbol, string | undefined>;
|
|
277
|
+
if (claims[SLASH_CLAIM] !== undefined && claims[SLASH_CLAIM] !== owner) return;
|
|
278
|
+
claims[SLASH_CLAIM] = owner;
|
|
279
|
+
// Release the name when this session ends, so /reload re-runs the race.
|
|
280
|
+
pi.on("session_shutdown", () => {
|
|
281
|
+
if (claims[SLASH_CLAIM] === owner) claims[SLASH_CLAIM] = undefined;
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const applySetting: ApplySetting = (field, on) => {
|
|
285
|
+
const patch: Partial<PiCcConfig> = { [field]: on };
|
|
286
|
+
const next = saveConfig(patch);
|
|
287
|
+
try {
|
|
288
|
+
pi.events.emit(CONFIG_CHANNEL, next);
|
|
289
|
+
} catch {
|
|
290
|
+
// another extension must not break the command
|
|
291
|
+
}
|
|
292
|
+
return next;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
pi.registerCommand("pi_cc", {
|
|
296
|
+
description: "pi-cc settings: message style, footer summary",
|
|
297
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
298
|
+
const words = args.trim().split(/\s+/).filter(Boolean);
|
|
299
|
+
// "/pi_cc settings footer on" reads like the old /loop settings: drop the noun.
|
|
300
|
+
if (words[0] === "settings" || words[0] === "config") words.shift();
|
|
301
|
+
if (words.length === 0) {
|
|
302
|
+
if (!(await openSettingsPanel(ctx, applySetting))) showSettings(ctx);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
// "/pi_cc help", or a word that is not a setting: the summary says what is.
|
|
306
|
+
const key = words[0].toLowerCase();
|
|
307
|
+
if (key === "help" || !KEYS[key]) {
|
|
308
|
+
showSettings(ctx);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
setSetting(ctx, applySetting, key, words[1]);
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
}
|