pi-cc-loop 0.4.0 → 0.5.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 CHANGED
@@ -38,7 +38,7 @@ ln -sfn "$PWD" ~/.pi/agent/extensions/pi-cc-loop
38
38
  | 次数上限 | `maxFires`,到顶自动删除 | `--max N` |
39
39
  | 持久化 | `durable: true` 写入 `.pi/scheduled_tasks.json`,重启后继续;同一项目只由一个 Pi 会话执行 | `--durable` |
40
40
  | 底部摘要 | Pi 底部状态栏一行:`loop 2 · next 3m`,无任务时不占位 | `/loop footer on\|off` |
41
- | 界面设置 | 本地命令,不进对话 | `/loop settings ...` |
41
+ | 界面设置 | 和 Pi 的 `/settings` 一样的面板,本地命令,不进对话 | `/loop settings` |
42
42
 
43
43
  ## 命令
44
44
 
@@ -49,7 +49,7 @@ ln -sfn "$PWD" ~/.pi/agent/extensions/pi-cc-loop
49
49
  /loop status 列出当前任务
50
50
  /loop stop [id] 停止一个或全部
51
51
  /loop footer on|off 本地:底部摘要
52
- /loop settings ... 本地:界面设置
52
+ /loop settings 本地:设置面板
53
53
  /loop help 本地:帮助
54
54
  ```
55
55
 
@@ -86,7 +86,7 @@ Ctrl+O 展开后多一行元信息(任务 id、节奏)。`compactMessages`
86
86
 
87
87
  ## 设置
88
88
 
89
- 两个 pi-cc 扩展共用 `~/.pi/agent/pi-cc.json`,文件不存在时用默认值:
89
+ 两个 pi-cc 扩展共用 Pi 配置目录下的 `pi-cc.json`(默认 `~/.pi/agent/pi-cc.json`,跟随 `PI_CODING_AGENT_DIR`),文件不存在时用默认值:
90
90
 
91
91
  ```jsonc
92
92
  {
@@ -95,7 +95,28 @@ Ctrl+O 展开后多一行元信息(任务 id、节奏)。`compactMessages`
95
95
  }
96
96
  ```
97
97
 
98
- 用 `/loop settings messages on|off`、`/loop settings footer on|off`(或 `/loop footer on|off`)切换,两个扩展同时生效。
98
+ 在 TUI 里输入 `/loop settings` 会打开设置面板,样子和操作都和 Pi 的 `/settings` 一样:上下键选择,Enter/空格切换,Esc 关闭;
99
+ 改动立即保存,两个扩展同时生效。Pi 的 `/settings` 不接受扩展加项,所以面板放在 `/loop settings` 下。
100
+
101
+ ```text
102
+ ─────────────────────────────────────────────────────────────
103
+ pi-cc settings · ~/.pi/agent/pi-cc.json
104
+
105
+ → Message style compact
106
+ Footer summary on
107
+
108
+ How scheduled fires and Monitor events are drawn. compact: ✻ header
109
+ and indented body; card: the old padded card.
110
+
111
+ Enter/Space to change · Esc to cancel
112
+ ─────────────────────────────────────────────────────────────
113
+ ```
114
+
115
+ 也可以用文字命令:`/loop settings messages on|off`、`/loop settings footer on|off`(或 `/loop footer on|off`),适合脚本和 RPC;
116
+ 非 TUI 模式下,不带参数的 `/loop settings` 输出当前设置。命令只写改动的那一项,文件里的其他内容原样保留。
117
+
118
+ 环境变量 `PI_CC_FOOTER`、`PI_CC_COMPACT_MESSAGES` 优先于文件,但不会被写进文件。设着的时候,面板里这一项显示实际生效的值、不能切换,
119
+ 说明里写出是哪个变量;文字输出会在对应项后面标出来(如 `hidden (PI_CC_FOOTER=0)`);用文字命令切换会写进文件,但提示会说明变量还在覆盖它。
99
120
 
100
121
  ## 底部摘要
101
122
 
@@ -124,7 +145,7 @@ Ctrl+O 展开后多一行元信息(任务 id、节奏)。`compactMessages`
124
145
  | `PI_LOOP_DURABLE_SYNC_MS` | `5000` | 持久化任务文件的同步间隔 |
125
146
  | `PI_CC_FOOTER` | — | `0` / `1`,覆盖设置文件里的 `footer` |
126
147
  | `PI_CC_COMPACT_MESSAGES` | — | `0` / `1`,覆盖设置文件里的 `compactMessages` |
127
- | `PI_CC_CONFIG_FILE` | `~/.pi/agent/pi-cc.json` | 设置文件路径 |
148
+ | `PI_CC_CONFIG_FILE` | `~/.pi/agent/pi-cc.json` | 设置文件路径;不设时在 Pi 的配置目录里,跟随 `PI_CODING_AGENT_DIR` |
128
149
 
129
150
  ## 兼容性
130
151
 
package/config.ts CHANGED
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Both pi-cc-loop and pi-cc-monitor read the same small JSON file so a single
5
5
  * toggle changes the look of both packages. The file is optional; when it is
6
- * missing or malformed the defaults apply.
6
+ * missing or malformed the defaults apply. It sits in Pi's agent directory
7
+ * (PI_CODING_AGENT_DIR, default ~/.pi/agent), next to Pi's own settings.
7
8
  *
8
9
  * ~/.pi/agent/pi-cc.json
9
10
  * {
@@ -11,14 +12,15 @@
11
12
  * "footer": true
12
13
  * }
13
14
  *
14
- * Environment overrides (useful for tests and quick experiments):
15
+ * Environment overrides (useful for tests and quick experiments) win over the
16
+ * file while they are set, and are never written to it:
15
17
  * PI_CC_CONFIG_FILE=/path/to/pi-cc.json
16
18
  * PI_CC_COMPACT_MESSAGES=0|1
17
19
  * PI_CC_FOOTER=0|1
18
20
  */
19
21
 
22
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
20
23
  import fs from "node:fs";
21
- import os from "node:os";
22
24
  import path from "node:path";
23
25
 
24
26
  export interface PiCcConfig {
@@ -36,6 +38,12 @@ export const DEFAULT_CONFIG: PiCcConfig = {
36
38
  footer: true,
37
39
  };
38
40
 
41
+ /** The environment variable that overrides each setting. */
42
+ const ENV_OVERRIDES: Record<keyof PiCcConfig, string> = {
43
+ compactMessages: "PI_CC_COMPACT_MESSAGES",
44
+ footer: "PI_CC_FOOTER",
45
+ };
46
+
39
47
  function envBool(name: string): boolean | undefined {
40
48
  const raw = process.env[name];
41
49
  if (raw === undefined) return undefined;
@@ -44,11 +52,16 @@ function envBool(name: string): boolean | undefined {
44
52
  return undefined;
45
53
  }
46
54
 
47
- /** The settings file shared by pi-cc-loop and pi-cc-monitor. */
55
+ /** The variable overriding a setting right now, as `PI_CC_FOOTER=0`; undefined when none is. */
56
+ export function envOverride(key: keyof PiCcConfig): string | undefined {
57
+ const name = ENV_OVERRIDES[key];
58
+ return envBool(name) === undefined ? undefined : `${name}=${process.env[name]}`;
59
+ }
60
+
61
+ /** The settings file shared by pi-cc-loop and pi-cc-monitor, in Pi's agent directory. */
48
62
  export function configPath(): string {
49
63
  if (process.env.PI_CC_CONFIG_FILE) return process.env.PI_CC_CONFIG_FILE;
50
- const agentDir = process.env.PI_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent");
51
- return path.join(agentDir, "pi-cc.json");
64
+ return path.join(getAgentDir(), "pi-cc.json");
52
65
  }
53
66
 
54
67
  function readConfig(): PiCcConfig {
@@ -61,10 +74,10 @@ function readConfig(): PiCcConfig {
61
74
  }
62
75
  return {
63
76
  compactMessages:
64
- envBool("PI_CC_COMPACT_MESSAGES") ??
77
+ envBool(ENV_OVERRIDES.compactMessages) ??
65
78
  (typeof file.compactMessages === "boolean" ? file.compactMessages : DEFAULT_CONFIG.compactMessages),
66
79
  footer:
67
- envBool("PI_CC_FOOTER") ?? (typeof file.footer === "boolean" ? file.footer : DEFAULT_CONFIG.footer),
80
+ envBool(ENV_OVERRIDES.footer) ?? (typeof file.footer === "boolean" ? file.footer : DEFAULT_CONFIG.footer),
68
81
  };
69
82
  }
70
83
 
@@ -81,9 +94,14 @@ export function invalidateConfig(): void {
81
94
  cached = undefined;
82
95
  }
83
96
 
84
- /** Merge a patch into the settings file and update the cache. */
97
+ /**
98
+ * Write the changed keys to the settings file and return the settings now in
99
+ * effect. Only the patch is written: every other key stays as the user left
100
+ * it, and an environment override is never copied into the file. It still
101
+ * wins over the file while it is set, so the returned value can differ from
102
+ * the patch (see `envOverride`).
103
+ */
85
104
  export function saveConfig(patch: Partial<PiCcConfig>): PiCcConfig {
86
- const next = { ...loadConfig(), ...patch };
87
105
  const file = configPath();
88
106
  fs.mkdirSync(path.dirname(file), { recursive: true });
89
107
  // Keep keys this version does not know about: the file is the user's.
@@ -95,8 +113,8 @@ export function saveConfig(patch: Partial<PiCcConfig>): PiCcConfig {
95
113
  // A missing or malformed file is rewritten from scratch.
96
114
  }
97
115
  const temp = `${file}.tmp`;
98
- fs.writeFileSync(temp, `${JSON.stringify({ ...raw, ...next }, null, 2)}\n`, "utf8");
116
+ fs.writeFileSync(temp, `${JSON.stringify({ ...raw, ...patch }, null, 2)}\n`, "utf8");
99
117
  fs.renameSync(temp, file);
100
- cached = next;
101
- return next;
118
+ cached = undefined;
119
+ return loadConfig();
102
120
  }
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 { configPath, invalidateConfig, loadConfig, saveConfig, type PiCcConfig } from "./config.ts";
32
+ import { configPath, envOverride, invalidateConfig, loadConfig, saveConfig, type PiCcConfig } 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 { settingsPanel } from "./settings-panel.ts";
60
61
  import {
61
62
  DEFAULT_TIMING,
62
63
  generateId,
@@ -574,7 +575,8 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
574
575
  case "statusline": {
575
576
  const arg = (rest[0] ?? "").toLowerCase();
576
577
  if (!arg) {
577
- notify(ctx, `Usage: /loop footer on|off (currently ${loadConfig().footer ? "on" : "off"})`, "info");
578
+ const current = `${loadConfig().footer ? "on" : "off"}${overrideNote("footer")}`;
579
+ notify(ctx, `Usage: /loop footer on|off (currently ${current})`, "info");
578
580
  return;
579
581
  }
580
582
  setSetting(ctx, "footer", arg);
@@ -584,7 +586,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
584
586
  case "config": {
585
587
  const [key, value] = rest;
586
588
  if (!key) {
587
- showSettings(ctx);
589
+ if (!(await openSettingsPanel(ctx))) showSettings(ctx);
588
590
  return;
589
591
  }
590
592
  const name = key.toLowerCase();
@@ -651,7 +653,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
651
653
  "/loop <prompt> self-paced: the agent picks the next delay",
652
654
  "/loop [interval] the .pi/loop.md tasks, or the built-in maintenance prompt",
653
655
  "/loop footer on|off show or hide the one-line summary in the footer",
654
- "/loop settings show UI settings",
656
+ "/loop settings settings panel (a summary outside the TUI)",
655
657
  "/loop settings messages on|off compact two-line markers",
656
658
  "/loop settings footer on|off the one-line footer summary",
657
659
  "",
@@ -662,12 +664,46 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
662
664
  notify(ctx, help, "info");
663
665
  }
664
666
 
667
+ /** ` (PI_CC_FOOTER=0)` when an environment variable decides the setting. */
668
+ function overrideNote(key: keyof PiCcConfig): string {
669
+ const override = envOverride(key);
670
+ return override ? ` (${override})` : "";
671
+ }
672
+
673
+ /**
674
+ * A bare /loop settings in the TUI: a panel like Pi's /settings, which
675
+ * extensions cannot add to. Resolves when the panel closes; false when there
676
+ * is no TUI to show it in, so the caller prints the summary instead.
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
+
665
701
  function showSettings(ctx: ExtensionContext): void {
666
702
  const config = loadConfig();
667
703
  const lines = [
668
704
  `pi-cc settings \u00b7 ${configPath()}`,
669
- ` messages: ${config.compactMessages ? "compact" : "card"}`,
670
- ` footer: ${config.footer ? "summary" : "hidden"}`,
705
+ ` messages: ${config.compactMessages ? "compact" : "card"}${overrideNote("compactMessages")}`,
706
+ ` footer: ${config.footer ? "summary" : "hidden"}${overrideNote("footer")}`,
671
707
  "",
672
708
  "Usage: /loop settings messages on|off",
673
709
  " /loop settings footer on|off",
@@ -683,15 +719,29 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
683
719
  showSettings(ctx);
684
720
  return;
685
721
  }
686
- const patch: Partial<PiCcConfig> = key === "messages" ? { compactMessages: on } : { footer: on };
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 };
687
737
  const next = saveConfig(patch);
688
738
  try {
689
739
  pi.events.emit(CONFIG_CHANNEL, next);
690
740
  } catch {
691
741
  // another extension must not break the command
692
742
  }
693
- notify(ctx, `${key === "messages" ? "Compact message style" : "Footer summary"} ${on ? "enabled" : "disabled"}`, "info");
694
- if (key === "footer") refreshStatus();
743
+ if (field === "footer") refreshStatus();
744
+ return next;
695
745
  }
696
746
 
697
747
  // ------------------------------------------------------------------ tools
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-loop",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",
@@ -0,0 +1,135 @@
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
+ }