pi-cc-loop 0.5.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 +19 -12
- package/config.ts +26 -5
- package/index.ts +16 -113
- package/package.json +1 -1
- package/settings.ts +314 -0
- package/settings-panel.ts +0 -135
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
|
-
| 界面设置 | 和 Pi 的 `/settings` 一样的面板,本地命令,不进对话 | `/
|
|
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,17 +86,24 @@ 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
|
-
|
|
99
|
-
|
|
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` 会重新认领)。
|
|
100
107
|
|
|
101
108
|
```text
|
|
102
109
|
─────────────────────────────────────────────────────────────
|
|
@@ -112,10 +119,10 @@ pi-cc settings · ~/.pi/agent/pi-cc.json
|
|
|
112
119
|
─────────────────────────────────────────────────────────────
|
|
113
120
|
```
|
|
114
121
|
|
|
115
|
-
也可以用文字命令:`/
|
|
116
|
-
非 TUI 模式下,不带参数的 `/
|
|
122
|
+
也可以用文字命令:`/pi_cc messages on|off`、`/pi_cc footer on|off`,适合脚本和 RPC;
|
|
123
|
+
非 TUI 模式下,不带参数的 `/pi_cc` 输出当前设置。命令只写改动的那一项,文件里的其他内容原样保留。
|
|
117
124
|
|
|
118
|
-
环境变量 `PI_CC_FOOTER`、`PI_CC_COMPACT_MESSAGES`
|
|
125
|
+
环境变量 `PI_CC_FOOTER`、`PI_CC_COMPACT_MESSAGES`(pi-cc-plugins 的两项是 `PI_CC_DRAFT`、`PI_CC_TURN_SUMMARY`)优先于文件,但不会被写进文件。设着的时候,面板里这一项显示实际生效的值、不能切换,
|
|
119
126
|
说明里写出是哪个变量;文字输出会在对应项后面标出来(如 `hidden (PI_CC_FOOTER=0)`);用文字命令切换会写进文件,但提示会说明变量还在覆盖它。
|
|
120
127
|
|
|
121
128
|
## 底部摘要
|
package/config.ts
CHANGED
|
@@ -1,22 +1,31 @@
|
|
|
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. It sits in Pi's agent
|
|
7
|
-
* (PI_CODING_AGENT_DIR, default ~/.pi/agent), next to Pi's own
|
|
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.
|
|
8
9
|
*
|
|
9
10
|
* ~/.pi/agent/pi-cc.json
|
|
10
11
|
* {
|
|
11
12
|
* "compactMessages": true,
|
|
12
|
-
* "footer": true
|
|
13
|
+
* "footer": true,
|
|
14
|
+
* "draft": true,
|
|
15
|
+
* "turnSummary": true
|
|
13
16
|
* }
|
|
14
17
|
*
|
|
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
|
+
*
|
|
15
22
|
* Environment overrides (useful for tests and quick experiments) win over the
|
|
16
23
|
* file while they are set, and are never written to it:
|
|
17
24
|
* PI_CC_CONFIG_FILE=/path/to/pi-cc.json
|
|
18
25
|
* PI_CC_COMPACT_MESSAGES=0|1
|
|
19
26
|
* PI_CC_FOOTER=0|1
|
|
27
|
+
* PI_CC_DRAFT=0|1
|
|
28
|
+
* PI_CC_TURN_SUMMARY=0|1
|
|
20
29
|
*/
|
|
21
30
|
|
|
22
31
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
@@ -31,17 +40,25 @@ export interface PiCcConfig {
|
|
|
31
40
|
* Shared so one switch silences both packages.
|
|
32
41
|
*/
|
|
33
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;
|
|
34
47
|
}
|
|
35
48
|
|
|
36
49
|
export const DEFAULT_CONFIG: PiCcConfig = {
|
|
37
50
|
compactMessages: true,
|
|
38
51
|
footer: true,
|
|
52
|
+
draft: true,
|
|
53
|
+
turnSummary: true,
|
|
39
54
|
};
|
|
40
55
|
|
|
41
56
|
/** The environment variable that overrides each setting. */
|
|
42
57
|
const ENV_OVERRIDES: Record<keyof PiCcConfig, string> = {
|
|
43
58
|
compactMessages: "PI_CC_COMPACT_MESSAGES",
|
|
44
59
|
footer: "PI_CC_FOOTER",
|
|
60
|
+
draft: "PI_CC_DRAFT",
|
|
61
|
+
turnSummary: "PI_CC_TURN_SUMMARY",
|
|
45
62
|
};
|
|
46
63
|
|
|
47
64
|
function envBool(name: string): boolean | undefined {
|
|
@@ -78,6 +95,10 @@ function readConfig(): PiCcConfig {
|
|
|
78
95
|
(typeof file.compactMessages === "boolean" ? file.compactMessages : DEFAULT_CONFIG.compactMessages),
|
|
79
96
|
footer:
|
|
80
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),
|
|
81
102
|
};
|
|
82
103
|
}
|
|
83
104
|
|
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,7 +57,7 @@ import {
|
|
|
57
57
|
loopCommandGuidelines,
|
|
58
58
|
} from "./prompts.ts";
|
|
59
59
|
import { formatClock, formatEta, renderScheduledMessage } from "./renderers.ts";
|
|
60
|
-
import {
|
|
60
|
+
import { CONFIG_CHANNEL, registerPiCcCommand } from "./settings.ts";
|
|
61
61
|
import {
|
|
62
62
|
DEFAULT_TIMING,
|
|
63
63
|
generateId,
|
|
@@ -74,7 +74,6 @@ const STATE_ENTRY = "pi-cc-loop-state";
|
|
|
74
74
|
const MONITOR_EVENT_TYPE = "Monitor";
|
|
75
75
|
/** Footer status key, shared with every other extension's status line. */
|
|
76
76
|
const STATUS_KEY = "loop";
|
|
77
|
-
const CONFIG_CHANNEL = "pi-cc:config";
|
|
78
77
|
|
|
79
78
|
/**
|
|
80
79
|
* The event channel this extension publishes on when a chain ends. pi-cc-monitor
|
|
@@ -569,38 +568,15 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
569
568
|
const raw = args.trim();
|
|
570
569
|
const [head, ...rest] = raw.split(/\s+/);
|
|
571
570
|
|
|
572
|
-
// 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).
|
|
573
573
|
switch ((head ?? "").toLowerCase()) {
|
|
574
574
|
case "footer":
|
|
575
|
-
case "statusline":
|
|
576
|
-
const arg = (rest[0] ?? "").toLowerCase();
|
|
577
|
-
if (!arg) {
|
|
578
|
-
const current = `${loadConfig().footer ? "on" : "off"}${overrideNote("footer")}`;
|
|
579
|
-
notify(ctx, `Usage: /loop footer on|off (currently ${current})`, "info");
|
|
580
|
-
return;
|
|
581
|
-
}
|
|
582
|
-
setSetting(ctx, "footer", arg);
|
|
583
|
-
return;
|
|
584
|
-
}
|
|
575
|
+
case "statusline":
|
|
585
576
|
case "settings":
|
|
586
|
-
case "config":
|
|
587
|
-
|
|
588
|
-
if (!key) {
|
|
589
|
-
if (!(await openSettingsPanel(ctx))) showSettings(ctx);
|
|
590
|
-
return;
|
|
591
|
-
}
|
|
592
|
-
const name = key.toLowerCase();
|
|
593
|
-
if (name === "messages" || name === "message") {
|
|
594
|
-
setSetting(ctx, "messages", value);
|
|
595
|
-
return;
|
|
596
|
-
}
|
|
597
|
-
if (name === "footer" || name === "status") {
|
|
598
|
-
setSetting(ctx, "footer", value);
|
|
599
|
-
return;
|
|
600
|
-
}
|
|
601
|
-
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");
|
|
602
579
|
return;
|
|
603
|
-
}
|
|
604
580
|
case "help":
|
|
605
581
|
case "--help":
|
|
606
582
|
case "-h":
|
|
@@ -652,10 +628,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
652
628
|
"/loop [interval] <prompt> run <prompt> every <interval> (e.g. 30s, 5m, 2h, 1d)",
|
|
653
629
|
"/loop <prompt> self-paced: the agent picks the next delay",
|
|
654
630
|
"/loop [interval] the .pi/loop.md tasks, or the built-in maintenance prompt",
|
|
655
|
-
"/
|
|
656
|
-
"/loop settings settings panel (a summary outside the TUI)",
|
|
657
|
-
"/loop settings messages on|off compact two-line markers",
|
|
658
|
-
"/loop settings footer on|off the one-line footer summary",
|
|
631
|
+
"/pi_cc pi-cc settings panel (message style, footer summary)",
|
|
659
632
|
"",
|
|
660
633
|
"Esc on a self-paced loop's own turn cancels that loop; fixed-interval loops keep running.",
|
|
661
634
|
"Flags: --durable write the job to .pi/scheduled_tasks.json so it survives a restart.",
|
|
@@ -664,85 +637,15 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
|
|
|
664
637
|
notify(ctx, help, "info");
|
|
665
638
|
}
|
|
666
639
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
return override ? ` (${override})` : "";
|
|
671
|
-
}
|
|
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");
|
|
672
643
|
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
async function openSettingsPanel(ctx: ExtensionCommandContext): Promise<boolean> {
|
|
679
|
-
if (ctx.mode !== "tui" || typeof ctx.ui.custom !== "function") return false;
|
|
680
|
-
await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
|
|
681
|
-
const panel = settingsPanel({
|
|
682
|
-
theme,
|
|
683
|
-
file: configPath(),
|
|
684
|
-
onChange: (field, value) => {
|
|
685
|
-
applySetting(field, value);
|
|
686
|
-
},
|
|
687
|
-
onClose: () => done(undefined),
|
|
688
|
-
});
|
|
689
|
-
return {
|
|
690
|
-
render: (width: number) => panel.render(width),
|
|
691
|
-
invalidate: () => panel.invalidate(),
|
|
692
|
-
handleInput: (data: string) => {
|
|
693
|
-
panel.handleInput(data);
|
|
694
|
-
tui.requestRender();
|
|
695
|
-
},
|
|
696
|
-
};
|
|
697
|
-
});
|
|
698
|
-
return true;
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
function showSettings(ctx: ExtensionContext): void {
|
|
702
|
-
const config = loadConfig();
|
|
703
|
-
const lines = [
|
|
704
|
-
`pi-cc settings \u00b7 ${configPath()}`,
|
|
705
|
-
` messages: ${config.compactMessages ? "compact" : "card"}${overrideNote("compactMessages")}`,
|
|
706
|
-
` footer: ${config.footer ? "summary" : "hidden"}${overrideNote("footer")}`,
|
|
707
|
-
"",
|
|
708
|
-
"Usage: /loop settings messages on|off",
|
|
709
|
-
" /loop settings footer on|off",
|
|
710
|
-
];
|
|
711
|
-
notify(ctx, lines.join("\n"), "info");
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
function setSetting(ctx: ExtensionContext, key: "messages" | "footer", raw: string | undefined): void {
|
|
715
|
-
const value = (raw ?? "").toLowerCase();
|
|
716
|
-
const on = value === "on" || value === "true" || value === "1";
|
|
717
|
-
const off = value === "off" || value === "false" || value === "0";
|
|
718
|
-
if (!on && !off) {
|
|
719
|
-
showSettings(ctx);
|
|
720
|
-
return;
|
|
721
|
-
}
|
|
722
|
-
const field: keyof PiCcConfig = key === "messages" ? "compactMessages" : "footer";
|
|
723
|
-
const next = applySetting(field, on);
|
|
724
|
-
const label = key === "messages" ? "Compact message style" : "Footer summary";
|
|
725
|
-
const override = envOverride(field);
|
|
726
|
-
if (override && next[field] !== on) {
|
|
727
|
-
// The file has the choice, but the variable decides while it is set.
|
|
728
|
-
notify(ctx, `${label} saved as ${on ? "on" : "off"}, but ${override} overrides it while set`, "warning");
|
|
729
|
-
} else {
|
|
730
|
-
notify(ctx, `${label} ${on ? "enabled" : "disabled"}`, "info");
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
/** Save one setting, tell the other pi-cc packages, and repaint the footer. Returns the settings in effect. */
|
|
735
|
-
function applySetting(field: keyof PiCcConfig, on: boolean): PiCcConfig {
|
|
736
|
-
const patch: Partial<PiCcConfig> = field === "compactMessages" ? { compactMessages: on } : { footer: on };
|
|
737
|
-
const next = saveConfig(patch);
|
|
738
|
-
try {
|
|
739
|
-
pi.events.emit(CONFIG_CHANNEL, next);
|
|
740
|
-
} catch {
|
|
741
|
-
// another extension must not break the command
|
|
742
|
-
}
|
|
743
|
-
if (field === "footer") refreshStatus();
|
|
744
|
-
return next;
|
|
745
|
-
}
|
|
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
|
+
});
|
|
746
649
|
|
|
747
650
|
// ------------------------------------------------------------------ tools
|
|
748
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
|
+
}
|
package/settings-panel.ts
DELETED
|
@@ -1,135 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The /loop settings panel: the same kind of screen as Pi's /settings.
|
|
3
|
-
*
|
|
4
|
-
* Pi's /settings cannot take items from an extension (its panel is a fixed
|
|
5
|
-
* list, and the command is handled before extension commands), so the shared
|
|
6
|
-
* pi-cc settings get their own panel, built from the same parts: SettingsList
|
|
7
|
-
* between two borders, the selected setting's description below it, and
|
|
8
|
-
* Enter/Space to change a value. A change is saved at once and Esc closes.
|
|
9
|
-
*
|
|
10
|
-
* A setting that an environment variable overrides shows the value in effect
|
|
11
|
-
* and cannot be changed here; its description names the variable.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
15
|
-
import { Container, SettingsList, Spacer, Text, type SettingItem, type SettingsListTheme } from "@earendil-works/pi-tui";
|
|
16
|
-
import os from "node:os";
|
|
17
|
-
import path from "node:path";
|
|
18
|
-
import { envOverride, loadConfig, type PiCcConfig } from "./config.ts";
|
|
19
|
-
|
|
20
|
-
/** The part of Pi's theme the panel uses. */
|
|
21
|
-
export interface PanelTheme {
|
|
22
|
-
fg(color: string, text: string): string;
|
|
23
|
-
bold(text: string): string;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
interface SettingSpec {
|
|
27
|
-
field: keyof PiCcConfig;
|
|
28
|
-
label: string;
|
|
29
|
-
/** Display values for true and false. */
|
|
30
|
-
on: string;
|
|
31
|
-
off: string;
|
|
32
|
-
description: string;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const SETTINGS: SettingSpec[] = [
|
|
36
|
-
{
|
|
37
|
-
field: "compactMessages",
|
|
38
|
-
label: "Message style",
|
|
39
|
-
on: "compact",
|
|
40
|
-
off: "card",
|
|
41
|
-
description:
|
|
42
|
-
"How scheduled fires and Monitor events are drawn. compact: \u273b header and indented body; card: the old padded card.",
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
field: "footer",
|
|
46
|
-
label: "Footer summary",
|
|
47
|
-
on: "on",
|
|
48
|
-
off: "off",
|
|
49
|
-
description: "One line in Pi's footer while loops or monitors run: loop 2 \u00b7 next 3m, monitor 2.",
|
|
50
|
-
},
|
|
51
|
-
];
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Pi's getSettingsListTheme(), built from the theme the panel is given: the
|
|
55
|
-
* global theme that function reads is not shared with extensions in every
|
|
56
|
-
* Pi runtime (see the note on Pi's DynamicBorder).
|
|
57
|
-
*/
|
|
58
|
-
function listTheme(theme: PanelTheme): SettingsListTheme {
|
|
59
|
-
return {
|
|
60
|
-
label: (text, selected) => (selected ? theme.fg("accent", text) : text),
|
|
61
|
-
value: (text, selected) => (selected ? theme.fg("accent", text) : theme.fg("muted", text)),
|
|
62
|
-
description: (text) => theme.fg("dim", text),
|
|
63
|
-
cursor: theme.fg("accent", "\u2192 "),
|
|
64
|
-
hint: (text) => theme.fg("dim", text),
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** A path with the home directory shortened to ~. */
|
|
69
|
-
function displayPath(file: string): string {
|
|
70
|
-
const home = os.homedir();
|
|
71
|
-
return file === home || file.startsWith(home + path.sep) ? `~${file.slice(home.length)}` : file;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function item(spec: SettingSpec): SettingItem {
|
|
75
|
-
const value = loadConfig()[spec.field] ? spec.on : spec.off;
|
|
76
|
-
const override = envOverride(spec.field);
|
|
77
|
-
if (override) {
|
|
78
|
-
// No values: Enter/Space leaves it alone, and the description says why.
|
|
79
|
-
return {
|
|
80
|
-
id: spec.field,
|
|
81
|
-
label: spec.label,
|
|
82
|
-
currentValue: value,
|
|
83
|
-
description: `${spec.description}\n${override} overrides this while set; unset it to change it here.`,
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
return { id: spec.field, label: spec.label, currentValue: value, values: [spec.on, spec.off], description: spec.description };
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export interface SettingsPanelOptions {
|
|
90
|
-
theme: PanelTheme;
|
|
91
|
-
/** Where the settings are saved, shown in the title. */
|
|
92
|
-
file: string;
|
|
93
|
-
/** Save one setting (and tell the other packages). */
|
|
94
|
-
onChange(field: keyof PiCcConfig, value: boolean): void;
|
|
95
|
-
onClose(): void;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export interface SettingsPanel {
|
|
99
|
-
render(width: number): string[];
|
|
100
|
-
invalidate(): void;
|
|
101
|
-
handleInput(data: string): void;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export function settingsPanel(options: SettingsPanelOptions): SettingsPanel {
|
|
105
|
-
const { theme } = options;
|
|
106
|
-
const list = new SettingsList(
|
|
107
|
-
SETTINGS.map(item),
|
|
108
|
-
SETTINGS.length,
|
|
109
|
-
listTheme(theme),
|
|
110
|
-
(id, value) => {
|
|
111
|
-
const spec = SETTINGS.find((candidate) => candidate.field === id);
|
|
112
|
-
if (!spec) return;
|
|
113
|
-
options.onChange(spec.field, value === spec.on);
|
|
114
|
-
// Show what is in effect now.
|
|
115
|
-
list.updateValue(id, loadConfig()[spec.field] ? spec.on : spec.off);
|
|
116
|
-
},
|
|
117
|
-
() => options.onClose(),
|
|
118
|
-
);
|
|
119
|
-
|
|
120
|
-
const border = () => new DynamicBorder((text: string) => theme.fg("border", text));
|
|
121
|
-
const container = new Container();
|
|
122
|
-
container.addChild(border());
|
|
123
|
-
container.addChild(
|
|
124
|
-
new Text(`${theme.fg("accent", theme.bold("pi-cc settings"))}${theme.fg("dim", ` \u00b7 ${displayPath(options.file)}`)}`, 0, 0),
|
|
125
|
-
);
|
|
126
|
-
container.addChild(new Spacer(1));
|
|
127
|
-
container.addChild(list);
|
|
128
|
-
container.addChild(border());
|
|
129
|
-
|
|
130
|
-
return {
|
|
131
|
-
render: (width) => container.render(width),
|
|
132
|
-
invalidate: () => container.invalidate(),
|
|
133
|
-
handleInput: (data) => list.handleInput(data),
|
|
134
|
-
};
|
|
135
|
-
}
|