pi-cc-loop 0.6.0 → 0.7.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`,无任务时不占位 | `/pi_cc footer on\|off` |
41
- | 界面设置 | 和 Pi 的 `/settings` 一样的面板,本地命令,不进对话 | `/pi_cc` |
41
+ | 界面设置 | 按插件分页的设置面板,本地命令,不进对话 | `/pi_cc` |
42
42
 
43
43
  ## 命令
44
44
 
@@ -86,43 +86,47 @@ Ctrl+O 展开后多一行元信息(任务 id、节奏)。`compactMessages`
86
86
 
87
87
  ## 设置
88
88
 
89
- 三个 pi-cc 扩展共用 Pi 配置目录下的 `pi-cc.json`(默认 `~/.pi/agent/pi-cc.json`,跟随 `PI_CODING_AGENT_DIR`),文件不存在时用默认值:
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
94
  "footer": true, // 底部摘要:loop 的一行和 Monitor 的 `monitor N`
95
95
  "draft": true, // pi-cc-plugins:Ctrl+C 找回
96
- "turnSummary": true // pi-cc-plugins:回合摘要一行
96
+ "turnSummary": true, // pi-cc-plugins:回合摘要一行
97
+ "compaction": true, // pi-cc-compact:Claude Code 的压缩
98
+ "idleCompaction": true // pi-cc-compact:空闲压缩
97
99
  }
98
100
  ```
99
101
 
100
- 后两项只被 pi-cc-plugins 读取;没装它的会话里,面板中的这两项不生效,值原样保留。
102
+ draft、turnSummary 只被 pi-cc-plugins 读取,compaction、idleCompaction 只被 pi-cc-compact 读取;没装对应包的会话里,面板中的这些项不生效,值原样保留。
101
103
 
102
- 在 TUI 里输入 `/pi_cc` 会打开设置面板,样子和操作都和 Pi 的 `/settings` 一样:上下键选择,Enter/空格切换,Esc 关闭;
103
- 改动立即保存,所有 pi-cc 扩展同时生效。Pi 的 `/settings` 不接受扩展加项,所以 pi-cc 的设置放在 `/pi_cc` 下。
104
+ 在 TUI 里输入 `/pi_cc` 会打开设置面板,和 pi-cc-extensions 的 `/ccstyle` 一样按页组织,每个插件一页:
105
+ **Loop & Monitor**(消息样式、底部摘要,两个包共用)、**Plugins**(pi-cc-plugins)、**Compact**(pi-cc-compact)。
106
+ Tab / Shift+Tab 切换页,上下键选择,Enter/空格切换,Esc 关闭;改动立即保存,所有 pi-cc 扩展同时生效。
107
+ Pi 的 `/settings` 不接受扩展加项,所以 pi-cc 的设置放在 `/pi_cc` 下。
104
108
 
105
- `/pi_cc` 由三个 pi-cc 包(loop、monitor、plugins)共同提供:装了其中任意一个,命令就在;装了多个,也只有一个 `/pi_cc`
109
+ `/pi_cc` 由四个 pi-cc 包(loop、monitor、plugins、compact)共同提供:装了其中任意一个,命令就在;装了多个,也只有一个 `/pi_cc`
106
110
  (Pi 会把不同扩展的同名命令变成 `/pi_cc:1`、`/pi_cc:2`,所以由最先加载的包认领注册,会话结束时释放,`/reload` 会重新认领)。
107
111
 
108
112
  ```text
109
- ─────────────────────────────────────────────────────────────
110
- pi-cc settings · ~/.pi/agent/pi-cc.json
111
-
112
- → Message style compact
113
- Footer summary on
113
+ ──────────────────────────────────────────────────────────────────────
114
+ Loop & Monitor / Plugins / Compact ~/.pi/agent/pi-cc.json
115
+ ──────────────────────────────────────────────────────────────────────
116
+ → Message style compact
117
+ Footer summary on
114
118
 
115
119
  How scheduled fires and Monitor events are drawn. compact: ✻ header
116
120
  and indented body; card: the old padded card.
117
-
118
- Enter/Space to change · Esc to cancel
119
- ─────────────────────────────────────────────────────────────
121
+ ──────────────────────────────────────────────────────────────────────
122
+ Enter/Space to change · Tab/Shift+Tab to switch pages · Esc to close
123
+ ──────────────────────────────────────────────────────────────────────
120
124
  ```
121
125
 
122
126
  也可以用文字命令:`/pi_cc messages on|off`、`/pi_cc footer on|off`,适合脚本和 RPC;
123
- 非 TUI 模式下,不带参数的 `/pi_cc` 输出当前设置。命令只写改动的那一项,文件里的其他内容原样保留。
127
+ 非 TUI 模式下,不带参数的 `/pi_cc` 按页输出当前设置。命令只写改动的那一项,文件里的其他内容原样保留。
124
128
 
125
- 环境变量 `PI_CC_FOOTER`、`PI_CC_COMPACT_MESSAGES`(pi-cc-plugins 的两项是 `PI_CC_DRAFT`、`PI_CC_TURN_SUMMARY`)优先于文件,但不会被写进文件。设着的时候,面板里这一项显示实际生效的值、不能切换,
129
+ 环境变量 `PI_CC_FOOTER`、`PI_CC_COMPACT_MESSAGES`(pi-cc-plugins 的两项是 `PI_CC_DRAFT`、`PI_CC_TURN_SUMMARY`,pi-cc-compact 的两项是 `PI_CC_COMPACTION`、`PI_CC_IDLE_COMPACTION`)优先于文件,但不会被写进文件。设着的时候,面板里这一项显示实际生效的值、不能切换,
126
130
  说明里写出是哪个变量;文字输出会在对应项后面标出来(如 `hidden (PI_CC_FOOTER=0)`);用文字命令切换会写进文件,但提示会说明变量还在覆盖它。
127
131
 
128
132
  ## 底部摘要
package/config.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Shared settings for the pi-cc extensions.
3
3
  *
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;
4
+ * pi-cc-loop, pi-cc-monitor, pi-cc-plugins and pi-cc-compact read the same
5
+ * small JSON file so a single toggle changes the whole family at once. The file is optional;
6
6
  * when it is missing or malformed the defaults apply. It sits in Pi's agent
7
7
  * directory (PI_CODING_AGENT_DIR, default ~/.pi/agent), next to Pi's own
8
8
  * settings.
@@ -12,11 +12,13 @@
12
12
  * "compactMessages": true,
13
13
  * "footer": true,
14
14
  * "draft": true,
15
- * "turnSummary": true
15
+ * "turnSummary": true,
16
+ * "compaction": true,
17
+ * "idleCompaction": true
16
18
  * }
17
19
  *
18
20
  * compactMessages and footer drive loop and monitor; draft and turnSummary
19
- * drive pi-cc-plugins. A package that is not installed simply ignores its
21
+ * drive pi-cc-plugins; compaction and idleCompaction drive pi-cc-compact. A package that is not installed simply ignores its
20
22
  * keys, and they are kept as the user left them.
21
23
  *
22
24
  * Environment overrides (useful for tests and quick experiments) win over the
@@ -26,6 +28,8 @@
26
28
  * PI_CC_FOOTER=0|1
27
29
  * PI_CC_DRAFT=0|1
28
30
  * PI_CC_TURN_SUMMARY=0|1
31
+ * PI_CC_COMPACTION=0|1
32
+ * PI_CC_IDLE_COMPACTION=0|1
29
33
  */
30
34
 
31
35
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
@@ -44,6 +48,10 @@ export interface PiCcConfig {
44
48
  draft: boolean;
45
49
  /** pi-cc-plugins: one line closes every agent run. */
46
50
  turnSummary: boolean;
51
+ /** pi-cc-compact: Claude Code's compaction replaces Pi's summary request. */
52
+ compaction: boolean;
53
+ /** pi-cc-compact: compact while idle, before a long prompt cache expires. */
54
+ idleCompaction: boolean;
47
55
  }
48
56
 
49
57
  export const DEFAULT_CONFIG: PiCcConfig = {
@@ -51,6 +59,8 @@ export const DEFAULT_CONFIG: PiCcConfig = {
51
59
  footer: true,
52
60
  draft: true,
53
61
  turnSummary: true,
62
+ compaction: true,
63
+ idleCompaction: true,
54
64
  };
55
65
 
56
66
  /** The environment variable that overrides each setting. */
@@ -59,6 +69,8 @@ const ENV_OVERRIDES: Record<keyof PiCcConfig, string> = {
59
69
  footer: "PI_CC_FOOTER",
60
70
  draft: "PI_CC_DRAFT",
61
71
  turnSummary: "PI_CC_TURN_SUMMARY",
72
+ compaction: "PI_CC_COMPACTION",
73
+ idleCompaction: "PI_CC_IDLE_COMPACTION",
62
74
  };
63
75
 
64
76
  function envBool(name: string): boolean | undefined {
@@ -75,7 +87,7 @@ export function envOverride(key: keyof PiCcConfig): string | undefined {
75
87
  return envBool(name) === undefined ? undefined : `${name}=${process.env[name]}`;
76
88
  }
77
89
 
78
- /** The settings file shared by pi-cc-loop and pi-cc-monitor, in Pi's agent directory. */
90
+ /** The settings file shared by the pi-cc packages, in Pi's agent directory. */
79
91
  export function configPath(): string {
80
92
  if (process.env.PI_CC_CONFIG_FILE) return process.env.PI_CC_CONFIG_FILE;
81
93
  return path.join(getAgentDir(), "pi-cc.json");
@@ -99,6 +111,12 @@ function readConfig(): PiCcConfig {
99
111
  turnSummary:
100
112
  envBool(ENV_OVERRIDES.turnSummary) ??
101
113
  (typeof file.turnSummary === "boolean" ? file.turnSummary : DEFAULT_CONFIG.turnSummary),
114
+ compaction:
115
+ envBool(ENV_OVERRIDES.compaction) ??
116
+ (typeof file.compaction === "boolean" ? file.compaction : DEFAULT_CONFIG.compaction),
117
+ idleCompaction:
118
+ envBool(ENV_OVERRIDES.idleCompaction) ??
119
+ (typeof file.idleCompaction === "boolean" ? file.idleCompaction : DEFAULT_CONFIG.idleCompaction),
102
120
  };
103
121
  }
104
122
 
package/cron.ts CHANGED
@@ -4,8 +4,9 @@
4
4
  * Fields: minute hour day-of-month month day-of-week
5
5
  * Per-field syntax: `*`, `*\/N`, `a`, `a-b`, `a-b/N`, `a,b,c`
6
6
  *
7
- * Deliberately unsupported (same subset Claude Code accepts): `L`, `W`, `?`
8
- * and name aliases such as `MON`.
7
+ * The subset Claude Code 2.1.308 accepts, nothing more: a step after a
8
+ * single value (`5/15`) is invalid there too, and `L`, `W`, `?` and name
9
+ * aliases such as `MON` are not supported.
9
10
  */
10
11
 
11
12
  export interface CronFields {
@@ -14,9 +15,9 @@ export interface CronFields {
14
15
  dom: Set<number>;
15
16
  month: Set<number>;
16
17
  dow: Set<number>;
17
- /** True when the day-of-month field was not `*`. */
18
+ /** True when the day-of-month field leaves days out (Claude Code: fewer than 31 values). */
18
19
  domRestricted: boolean;
19
- /** True when the day-of-week field was not `*`. */
20
+ /** True when the day-of-week field leaves days out (Claude Code: fewer than 7 values). */
20
21
  dowRestricted: boolean;
21
22
  }
22
23
 
@@ -59,6 +60,8 @@ function parseField(spec: FieldSpec, raw: string): Set<number> | null {
59
60
  } else {
60
61
  const m = /^(\d+)(?:-(\d+))?$/.exec(rangePart);
61
62
  if (!m) return null;
63
+ // "5/15": Claude Code rejects a step after a single value.
64
+ if (stepPart !== undefined && m[2] === undefined) return null;
62
65
  start = Number(m[1]);
63
66
  end = m[2] !== undefined ? Number(m[2]) : start;
64
67
  if (end < start) return null;
@@ -93,8 +96,9 @@ export function parseCron(expression: string): CronFields | null {
93
96
  dom: parsed[2],
94
97
  month: parsed[3],
95
98
  dow: parsed[4],
96
- domRestricted: tokens[2] !== "*",
97
- dowRestricted: tokens[4] !== "*",
99
+ // Claude Code compares with the full range, so 1-31 or */1 is every day.
100
+ domRestricted: parsed[2].size < 31,
101
+ dowRestricted: parsed[4].size < 7,
98
102
  };
99
103
  }
100
104
 
@@ -233,17 +237,3 @@ function clockText(minute: number, hour: number): string {
233
237
  const display = hour % 12 === 0 ? 12 : hour % 12;
234
238
  return `${display}:${String(minute).padStart(2, "0")} ${suffix}`;
235
239
  }
236
-
237
- /** Human readable summary of a cron expression. */
238
- export function describeCron(expression: string): string {
239
- const fields = parseCron(expression);
240
- if (!fields) return expression;
241
- const [minute, hour, dom, month, dow] = expression.trim().split(/\s+/);
242
- const parts: string[] = [];
243
- if (minute !== "*") parts.push(`minute ${minute}`);
244
- if (hour !== "*") parts.push(`hour ${hour}`);
245
- if (dom !== "*") parts.push(`day-of-month ${dom}`);
246
- if (month !== "*") parts.push(`month ${month}`);
247
- if (dow !== "*") parts.push(`day-of-week ${dow}`);
248
- return parts.length ? parts.join(", ") : "every minute";
249
- }
package/index.ts CHANGED
@@ -86,7 +86,7 @@ const LOOP_STOPPED_CHANNEL = "loop:stopped";
86
86
  const MAX_JOBS = 50;
87
87
  const MIN_DELAY_SECONDS = 60;
88
88
  const MAX_DELAY_SECONDS = 3600;
89
- /** A wakeup just under the 5 minute prompt cache lifetime is pulled inside it. */
89
+ /** The one fallback wakeup a self-paced chain gets when the model forgot to reschedule. */
90
90
  const DEFAULT_KEEPALIVE_SECONDS = 1200;
91
91
  const DEFAULT_MAX_AGE_DAYS = 7;
92
92
  /** How many stopped prompts are remembered (and persisted). */
@@ -628,7 +628,7 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
628
628
  "/loop [interval] <prompt> run <prompt> every <interval> (e.g. 30s, 5m, 2h, 1d)",
629
629
  "/loop <prompt> self-paced: the agent picks the next delay",
630
630
  "/loop [interval] the .pi/loop.md tasks, or the built-in maintenance prompt",
631
- "/pi_cc pi-cc settings panel (message style, footer summary)",
631
+ "/pi_cc pi-cc settings, one page per plugin",
632
632
  "",
633
633
  "Esc on a self-paced loop's own turn cancels that loop; fixed-interval loops keep running.",
634
634
  "Flags: --durable write the job to .pi/scheduled_tasks.json so it survives a restart.",
package/loopfile.ts CHANGED
@@ -65,7 +65,7 @@ export type TickMode = "cron" | "wakeup";
65
65
  export class LoopFileDelivery {
66
66
  private lastDelivered: string | null = null;
67
67
 
68
- /** Send the full text on the next tick (a new /loop, or after compaction). */
68
+ /** Send the full text on the next tick: after a compaction the earlier copy is gone. */
69
69
  reset(): void {
70
70
  this.lastDelivered = null;
71
71
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-loop",
3
- "version": "0.6.0",
3
+ "version": "0.7.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/render.ts CHANGED
@@ -57,15 +57,6 @@ export function formatDuration(ms: number): string {
57
57
  return rest ? `${minutes}m ${rest}s` : `${minutes}m`;
58
58
  }
59
59
 
60
- /** The first non-empty line of a text block. */
61
- export function firstLine(text: string): string {
62
- for (const line of text.split("\n")) {
63
- const trimmed = line.trim();
64
- if (trimmed) return trimmed;
65
- }
66
- return "";
67
- }
68
-
69
60
  /** Split a text block into displayable lines, dropping blank ones. */
70
61
  export function bodyLines(text: string): string[] {
71
62
  return text
package/settings.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * The /pi_cc command: the settings every pi-cc package shares.
3
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
4
+ * This file is copied verbatim between pi-cc-loop, pi-cc-monitor,
5
+ * pi-cc-plugins and pi-cc-compact (separate npm packages); each repo's tests/shared.test.mjs
6
6
  * fails when the copies drift apart. The settings live in one pi-cc.json and
7
7
  * apply to every pi-cc package installed. A change is announced on the
8
8
  * "pi-cc:config" channel, so the packages repaint what the settings change
@@ -10,12 +10,15 @@
10
10
  *
11
11
  * Pi's /settings cannot take items from an extension (its panel is a fixed
12
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.
13
+ * opens its own, laid out like pi-cc-extensions' /ccstyle: one page per
14
+ * plugin, a tab bar on top (Tab and Shift+Tab switch pages), and on each page
15
+ * Pi's SettingsList with the selected setting's description below it.
16
+ * Enter/Space changes a value; a change is saved at once and Esc closes.
17
+ * Message style and the footer summary serve pi-cc-loop and pi-cc-monitor
18
+ * alike, so the two share a page. A setting that an environment variable
19
+ * overrides shows the value in effect and cannot be changed here; its
20
+ * description names the variable. Outside the TUI, /pi_cc prints the summary,
21
+ * page by page.
19
22
  *
20
23
  * Only one /pi_cc exists however many pi-cc packages are installed: Pi turns
21
24
  * same-named commands from different extensions into /pi_cc:1 and /pi_cc:2,
@@ -27,8 +30,8 @@
27
30
  * getCommands() cannot be called during loading.
28
31
  */
29
32
 
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";
33
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
34
+ import { matchesKey, SettingsList, truncateToWidth, visibleWidth, type SettingItem, type SettingsListTheme } from "@earendil-works/pi-tui";
32
35
  import os from "node:os";
33
36
  import path from "node:path";
34
37
  import { configPath, envOverride, loadConfig, saveConfig, type PiCcConfig } from "./config.ts";
@@ -54,42 +57,83 @@ interface SettingSpec {
54
57
  description: string;
55
58
  }
56
59
 
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
- },
60
+ interface SettingsPage {
61
+ label: string;
62
+ settings: SettingSpec[];
63
+ }
64
+
65
+ const PAGES: SettingsPage[] = [
66
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.",
67
+ label: "Loop & Monitor",
68
+ settings: [
69
+ {
70
+ field: "compactMessages",
71
+ label: "Message style",
72
+ on: "compact",
73
+ off: "card",
74
+ description:
75
+ "How scheduled fires and Monitor events are drawn. compact: \u273b header and indented body; card: the old padded card.",
76
+ },
77
+ {
78
+ field: "footer",
79
+ label: "Footer summary",
80
+ on: "on",
81
+ off: "off",
82
+ description: "One line in Pi's footer while loops or monitors run: loop 2 \u00b7 next 3m, monitor 2.",
83
+ },
84
+ ],
72
85
  },
73
86
  {
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.",
87
+ label: "Plugins",
88
+ settings: [
89
+ {
90
+ field: "draft",
91
+ label: "Draft recall",
92
+ on: "on",
93
+ off: "off",
94
+ description: "Ctrl+C keeps what it clears; Up on the empty editor brings it back.",
95
+ },
96
+ {
97
+ field: "turnSummary",
98
+ label: "Turn summary",
99
+ on: "on",
100
+ off: "off",
101
+ description: "One line closes every agent run \u2014 tools, duration, done time.",
102
+ },
103
+ ],
79
104
  },
80
105
  {
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.",
106
+ label: "Compact",
107
+ settings: [
108
+ {
109
+ field: "compaction",
110
+ label: "CC compaction",
111
+ on: "on",
112
+ off: "off",
113
+ description:
114
+ "Compaction summarizes the way Claude Code does \u2014 the same prompt and thresholds, reusing the prompt cache. off: Pi's own compaction.",
115
+ },
116
+ {
117
+ field: "idleCompaction",
118
+ label: "Idle compaction",
119
+ on: "on",
120
+ off: "off",
121
+ description:
122
+ "Compact while idle, before a prompt cache of an hour or more expires (models with promptCache).",
123
+ },
124
+ ],
86
125
  },
87
126
  ];
88
127
 
128
+ const SETTINGS: SettingSpec[] = PAGES.flatMap((page) => page.settings);
129
+
130
+ /** Labels padded to one width, so the values stay in one column from page to page. */
131
+ const LABEL_WIDTH = Math.max(...SETTINGS.map((spec) => spec.label.length));
132
+
89
133
  /**
90
134
  * Pi's getSettingsListTheme(), built from the theme the panel is given: the
91
135
  * global theme that function reads is not shared with extensions in every
92
- * Pi runtime (see the note on Pi's DynamicBorder).
136
+ * Pi runtime.
93
137
  */
94
138
  function listTheme(theme: PanelTheme): SettingsListTheme {
95
139
  return {
@@ -110,16 +154,17 @@ function displayPath(file: string): string {
110
154
  function item(spec: SettingSpec): SettingItem {
111
155
  const value = loadConfig()[spec.field] ? spec.on : spec.off;
112
156
  const override = envOverride(spec.field);
157
+ const label = spec.label.padEnd(LABEL_WIDTH);
113
158
  if (override) {
114
159
  // No values: Enter/Space leaves it alone, and the description says why.
115
160
  return {
116
161
  id: spec.field,
117
- label: spec.label,
162
+ label,
118
163
  currentValue: value,
119
164
  description: `${spec.description}\n${override} overrides this while set; unset it to change it here.`,
120
165
  };
121
166
  }
122
- return { id: spec.field, label: spec.label, currentValue: value, values: [spec.on, spec.off], description: spec.description };
167
+ return { id: spec.field, label, currentValue: value, values: [spec.on, spec.off], description: spec.description };
123
168
  }
124
169
 
125
170
  export interface SettingsPanelOptions {
@@ -137,36 +182,69 @@ export interface SettingsPanel {
137
182
  handleInput(data: string): void;
138
183
  }
139
184
 
185
+ const HINT = " Enter/Space to change \u00b7 Tab/Shift+Tab to switch pages \u00b7 Esc to close";
186
+
187
+ function isNextPageKey(data: string): boolean {
188
+ return data === "\t" || matchesKey(data, "tab");
189
+ }
190
+
191
+ function isPreviousPageKey(data: string): boolean {
192
+ // CSI Z is how most terminals send Shift+Tab.
193
+ return data === "\x1b[Z" || matchesKey(data, "shift+tab");
194
+ }
195
+
140
196
  export function settingsPanel(options: SettingsPanelOptions): SettingsPanel {
141
197
  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
- );
198
+ const lists = PAGES.map((page) => {
199
+ const list: SettingsList = new SettingsList(
200
+ page.settings.map(item),
201
+ page.settings.length,
202
+ listTheme(theme),
203
+ (id, value) => {
204
+ const spec = page.settings.find((candidate) => candidate.field === id);
205
+ if (!spec) return;
206
+ options.onChange(spec.field, value === spec.on);
207
+ // Show what is in effect now.
208
+ list.updateValue(id, loadConfig()[spec.field] ? spec.on : spec.off);
209
+ },
210
+ () => options.onClose(),
211
+ );
212
+ return list;
213
+ });
214
+ let active = 0;
155
215
 
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());
216
+ const rule = (width: number) => theme.fg("dim", "\u2500".repeat(Math.max(0, width)));
217
+ const tabBar = (width: number) => {
218
+ const tabs = PAGES.map((page, index) =>
219
+ index === active ? theme.fg("text", theme.bold(page.label)) : theme.fg("dim", page.label),
220
+ ).join(theme.fg("dim", " / "));
221
+ // The file the settings are saved in, on the right when it fits.
222
+ const file = displayPath(options.file);
223
+ const gap = width - visibleWidth(tabs) - visibleWidth(file);
224
+ return gap >= 2 ? `${tabs}${" ".repeat(gap)}${theme.fg("dim", file)}` : truncateToWidth(tabs, width);
225
+ };
226
+ /** The open page's list, without SettingsList's own hint: the panel has one below. */
227
+ const body = (width: number) => {
228
+ const lines = lists[active].render(width);
229
+ const hint = lines.findIndex((line) => line.includes("Esc to cancel"));
230
+ const kept = hint === -1 ? lines : lines.slice(0, hint);
231
+ while (kept.length > 0 && kept[kept.length - 1].trim() === "") kept.pop();
232
+ return kept;
233
+ };
165
234
 
166
235
  return {
167
- render: (width) => container.render(width),
168
- invalidate: () => container.invalidate(),
169
- handleInput: (data) => list.handleInput(data),
236
+ render: (width) => {
237
+ const safe = Math.max(0, Math.floor(width));
238
+ return [rule(safe), tabBar(safe), rule(safe), ...body(safe), rule(safe), truncateToWidth(theme.fg("dim", HINT), safe), rule(safe)];
239
+ },
240
+ invalidate: () => {
241
+ for (const list of lists) list.invalidate();
242
+ },
243
+ handleInput: (data) => {
244
+ if (isNextPageKey(data)) active = (active + 1) % PAGES.length;
245
+ else if (isPreviousPageKey(data)) active = (active - 1 + PAGES.length) % PAGES.length;
246
+ else lists[active].handleInput(data);
247
+ },
170
248
  };
171
249
  }
172
250
 
@@ -188,15 +266,22 @@ function showSettings(ctx: ExtensionContext): void {
188
266
  const config = loadConfig();
189
267
  const lines = [
190
268
  `pi-cc settings \u00b7 ${configPath()}`,
269
+ PAGES[0].label,
191
270
  ` messages: ${config.compactMessages ? "compact" : "card"}${overrideNote("compactMessages")}`,
192
271
  ` footer: ${config.footer ? "summary" : "hidden"}${overrideNote("footer")}`,
272
+ PAGES[1].label,
193
273
  ` draft: ${config.draft ? "on" : "off"}${overrideNote("draft")}`,
194
274
  ` summary: ${config.turnSummary ? "on" : "off"}${overrideNote("turnSummary")}`,
275
+ PAGES[2].label,
276
+ ` compact: ${config.compaction ? "on" : "off"}${overrideNote("compaction")}`,
277
+ ` idle: ${config.idleCompaction ? "on" : "off"}${overrideNote("idleCompaction")}`,
195
278
  "",
196
279
  "Usage: /pi_cc messages on|off",
197
280
  " /pi_cc footer on|off",
198
281
  " /pi_cc draft on|off",
199
282
  " /pi_cc summary on|off",
283
+ " /pi_cc compact on|off",
284
+ " /pi_cc idle on|off",
200
285
  ];
201
286
  notify(ctx, lines.join("\n"), "info");
202
287
  }
@@ -242,6 +327,9 @@ const KEYS: Record<string, { field: keyof PiCcConfig; label: string }> = {
242
327
  draft: { field: "draft", label: "Draft recall" },
243
328
  summary: { field: "turnSummary", label: "Turn summary" },
244
329
  turnsummary: { field: "turnSummary", label: "Turn summary" },
330
+ compact: { field: "compaction", label: "CC compaction" },
331
+ compaction: { field: "compaction", label: "CC compaction" },
332
+ idle: { field: "idleCompaction", label: "Idle compaction" },
245
333
  };
246
334
 
247
335
  function setSetting(ctx: ExtensionContext, apply: ApplySetting, key: string, raw: string | undefined): void {
@@ -293,7 +381,7 @@ export function registerPiCcCommand(pi: ExtensionAPI, owner: string): void {
293
381
  };
294
382
 
295
383
  pi.registerCommand("pi_cc", {
296
- description: "pi-cc settings: message style, footer summary",
384
+ description: "pi-cc settings: message style, footer, draft, turn summary, compaction",
297
385
  handler: async (args: string, ctx: ExtensionCommandContext) => {
298
386
  const words = args.trim().split(/\s+/).filter(Boolean);
299
387
  // "/pi_cc settings footer on" reads like the old /loop settings: drop the noun.