pi-native-output-styles 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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Added
6
+
7
+ - `/output-style config` for the two settings worth persisting. It runs from the user config file, so both apply to every session and project:
8
+ - `config default <style|off>` — the style new sessions start with.
9
+ - `config indicator <status|widget|off>` — where the active style is shown; `status` is the default.
10
+ - Run `config` with no arguments for a dialog per setting. A bare key reads the value back, an unknown key is an error, and a run without a dialog UI prints the config instead.
11
+ - A `widget` indicator renders the style above the editor. Every refresh writes both surfaces, so switching modes can never leave a stale badge.
12
+
13
+ ### Changed
14
+
15
+ - `config` joins the management words in the command router. `configure this style` still routes to the agent.
16
+ - Clearing a saved default now merges state instead of overwriting the file, so it no longer drops the indicator setting.
17
+
3
18
  ## 0.5.0
4
19
 
5
20
  One command, plus a bundled style and an agent that maintains styles.
package/README.md CHANGED
@@ -41,7 +41,17 @@ Then start a **new** session. Extensions do not hot-reload.
41
41
  /output-style 创建一个适合代码 Review 的 output style
42
42
  ```
43
43
 
44
- The rule is one line: *a single word that names a style, `off`, or `none` is management; anything else is a request for the agent.* So `/output-style concise` activates, and `/output-style rewrite concise` asks. To review a style whose name you would otherwise activate, say more than its name.
44
+ **Configure it** `/output-style config`:
45
+
46
+ ```text
47
+ /output-style config interactive: pick the default and the indicator
48
+ /output-style config default caveman set the cross-session default
49
+ /output-style config default off clear it
50
+ /output-style config indicator widget where the active style shows
51
+ /output-style config indicator read one setting back
52
+ ```
53
+
54
+ The rule is one line: *`config` and a single word that names a style, `off`, or `none` are management; anything else is a request for the agent.* So `/output-style concise` activates, and `/output-style rewrite concise` asks. To review a style whose name you would otherwise activate, say more than its name.
45
55
 
46
56
  While composing the command, a hint line under the editor shows both forms.
47
57
 
@@ -97,12 +107,27 @@ Precedence:
97
107
 
98
108
  ## Config
99
109
 
110
+ `/output-style config` holds the two things that are worth setting once. Both live in `~/.pi/agent/output-styles.json`, so they apply to every session and every project without re-stating anything.
111
+
112
+ | Key | Values | What it does |
113
+ | --- | --- | --- |
114
+ | `default` | a style name, or `off` | The style every new session starts with. Alias: `style`. |
115
+ | `indicator` | `status`, `widget`, `off` | Where the active style shows. Default `status`. |
116
+
117
+ A bare key reads the value back. An unknown key is an error, not a silent no-op. Running `config` with no arguments opens a dialog per setting; in a non-dialog run (print or JSON mode) it prints the current config instead.
118
+
119
+ The indicator is a display preference, so like the default style the personal setting wins over a project one, and a project setting applies when you have none.
120
+
121
+ To set a default without opening dialogs, `/output-style <name> --save` does the same thing.
122
+
123
+ ## File locations
124
+
100
125
  | | Path |
101
126
  | --- | --- |
102
127
  | Project styles | `<repo>/.pi/output-styles/` |
103
128
  | User styles | `~/.pi/agent/output-styles/` |
104
129
  | Project default (`--project`) | `<repo>/.pi/output-styles.json` |
105
- | User default (`--save`) | `~/.pi/agent/output-styles.json` |
130
+ | User default + config (`--save`, `config`) | `~/.pi/agent/output-styles.json` |
106
131
 
107
132
  Environment:
108
133
 
@@ -35,6 +35,7 @@ interface ExtensionUI {
35
35
  setWidget(key: string, lines: string[] | undefined, options?: { placement: "aboveEditor" | "belowEditor" }): void;
36
36
  getEditorText(): string;
37
37
  notify(message: string, type?: NotifyType): void;
38
+ select?(title: string, options: string[]): Promise<string | undefined>;
38
39
  }
39
40
 
40
41
  interface ExtensionContext {
@@ -143,13 +144,25 @@ export function bundledStylesDir(): string {
143
144
 
144
145
  export interface StyleState {
145
146
  active?: string;
147
+ indicator?: IndicatorMode;
148
+ }
149
+
150
+ /** Where the active style is shown. `status` is the default. */
151
+ export const INDICATOR_MODES = ["status", "widget", "off"] as const;
152
+ export type IndicatorMode = (typeof INDICATOR_MODES)[number];
153
+
154
+ export function isIndicatorMode(value: unknown): value is IndicatorMode {
155
+ return typeof value === "string" && (INDICATOR_MODES as readonly string[]).includes(value);
146
156
  }
147
157
 
148
158
  export function readState(file: string): StyleState {
149
159
  try {
150
160
  const parsed: unknown = JSON.parse(readFileSync(file, "utf8"));
151
- if (parsed && typeof parsed === "object" && "active" in parsed && typeof parsed.active === "string") {
152
- return { active: parsed.active };
161
+ if (parsed && typeof parsed === "object") {
162
+ const state: StyleState = {};
163
+ if ("active" in parsed && typeof parsed.active === "string") state.active = parsed.active;
164
+ if ("indicator" in parsed && isIndicatorMode(parsed.indicator)) state.indicator = parsed.indicator;
165
+ return state;
153
166
  }
154
167
  } catch {
155
168
  // missing or malformed → empty
@@ -162,6 +175,13 @@ export function writeState(file: string, state: StyleState): void {
162
175
  writeFileSync(file, JSON.stringify(state, null, 2) + "\n");
163
176
  }
164
177
 
178
+ // Merge, never replace: clearing the default must not drop the indicator
179
+ // setting, and setting the indicator must not drop the default. A key passed
180
+ // as undefined drops out of the JSON, which is how a key is cleared.
181
+ export function updateState(file: string, patch: StyleState): void {
182
+ writeState(file, { ...readState(file), ...patch });
183
+ }
184
+
165
185
  export function userStateFile(): string {
166
186
  return join(configHome(), "output-styles.json");
167
187
  }
@@ -179,6 +199,12 @@ export function resolveActiveName(
179
199
  return sessionActive ?? userState.active ?? projectState.active ?? null;
180
200
  }
181
201
 
202
+ // The indicator is presentation, so it resolves from the same places as the
203
+ // default style but falls back to the status bar.
204
+ export function resolveIndicator(cwd: string): IndicatorMode {
205
+ return readState(userStateFile()).indicator ?? readState(projectStateFile(cwd)).indicator ?? "status";
206
+ }
207
+
182
208
  const MARKER_PREFIX = "<!-- output-styles:";
183
209
 
184
210
  export function styleMarker(style: Style): string {
@@ -224,16 +250,20 @@ export function parseStyleCommandArgs(args: string): StyleCommandArgs {
224
250
  return { name, persist };
225
251
  }
226
252
 
227
- // One command serves both jobs, so the split has to be guessable from the
228
- // words alone. Rule: the request is style management only when it is empty,
229
- // or its single non-flag word is `off`/`none` or a style that exists. Anything
230
- // else is a task for the agent. `/output-style concise` activates; `/output-style
231
- // rewrite concise` asks the agent.
232
- export type StyleCommandRoute = { kind: "manage" } | { kind: "task"; request: string };
253
+ // One command serves every job, so the split has to be guessable from the
254
+ // words alone. Rule, in order: the request is `config` when it starts with the
255
+ // config word; empty, `off`, `none`, or a single word naming an existing style
256
+ // is style management; anything else is a task for the agent. `/output-style
257
+ // concise` activates; `/output-style rewrite concise` asks the agent.
258
+ export type StyleCommandRoute =
259
+ | { kind: "config"; args: string }
260
+ | { kind: "manage" }
261
+ | { kind: "task"; request: string };
233
262
 
234
263
  export function routeStyleCommand(args: string, styleNames: Iterable<string>): StyleCommandRoute {
235
264
  const request = args.trim();
236
265
  if (request.length === 0) return { kind: "manage" };
266
+ if (/^config(?=\s|$)/i.test(request)) return { kind: "config", args: request.replace(/^config\s*/i, "").trim() };
237
267
  const words = request.split(/\s+/).filter(t => t.length > 0 && !t.startsWith("--"));
238
268
  if (words.length === 0) return { kind: "manage" }; // flags only
239
269
  if (words.length > 1) return { kind: "task", request };
@@ -251,13 +281,14 @@ export function resolveStyleName(name: string, styleNames: Iterable<string>): st
251
281
  }
252
282
 
253
283
  const STATUS_KEY = "output-styles";
284
+ const INDICATOR_KEY = "output-styles-indicator";
254
285
  const HINT_KEY = "output-styles-hint";
255
286
  // Persistent ghost hint shown below the editor while `/output-style` is being
256
287
  // composed. Pi only renders inline usage ghost text for builtin commands, so
257
288
  // this widget carries the same message for extension commands.
258
289
  const STYLE_HINT_LINES = [
259
290
  "/output-style <name|off> [--save] [--project]",
260
- "/output-style <ask the agent to review, rewrite, or create a style>",
291
+ "/output-style config default style, indicator",
261
292
  ];
262
293
 
263
294
  // Pure matcher for the widget: show the hint while the input starts with the
@@ -360,9 +391,19 @@ export function resolveActiveStyle(cwd: string, styles?: Map<string, Style>): St
360
391
  return map.get(name) ?? null;
361
392
  }
362
393
 
363
- function refreshStatus(ctx: ExtensionContext, style: Style | null): void {
364
- if (!ctx.hasUI || typeof ctx.ui.setStatus !== "function") return;
365
- ctx.ui.setStatus(STATUS_KEY, style ? `style: ${style.name}` : undefined);
394
+ // One renderer for every call site, so switching the mode can never leave a
395
+ // stale badge behind: both surfaces are written on every refresh, and only the
396
+ // configured one gets text.
397
+ function renderIndicator(ctx: ExtensionContext, style: Style | null): void {
398
+ if (!ctx.hasUI) return;
399
+ const mode = resolveIndicator(ctx.cwd);
400
+ const label = style ? `style: ${style.name}` : undefined;
401
+ if (typeof ctx.ui.setStatus === "function") {
402
+ ctx.ui.setStatus(STATUS_KEY, mode === "status" ? label : undefined);
403
+ }
404
+ if (typeof ctx.ui.setWidget === "function") {
405
+ ctx.ui.setWidget(INDICATOR_KEY, mode === "widget" && label ? [label] : undefined, { placement: "aboveEditor" });
406
+ }
366
407
  }
367
408
 
368
409
  // The Leader brief is plain Markdown next to the extension, so its wording can
@@ -396,9 +437,104 @@ export function buildStyleTask(brief: string, cwd: string, active: Style | null,
396
437
  ].join("\n");
397
438
  }
398
439
 
440
+ export type ConfigKey = "default" | "indicator";
441
+
442
+ export interface ConfigArgs {
443
+ /** null means "no key given" — open the interactive flow. */
444
+ key: ConfigKey | null;
445
+ value: string;
446
+ }
447
+
448
+ const CONFIG_KEY_ALIASES: Record<string, ConfigKey> = {
449
+ default: "default",
450
+ style: "default",
451
+ indicator: "indicator",
452
+ };
453
+
454
+ // `null` means an unrecognised key; `{key: null}` means no key at all.
455
+ export function parseConfigArgs(args: string): ConfigArgs | null {
456
+ const tokens = args.trim().split(/\s+/).filter(t => t.length > 0);
457
+ if (tokens.length === 0) return { key: null, value: "" };
458
+ const key = CONFIG_KEY_ALIASES[tokens[0].toLowerCase()];
459
+ if (!key) return null;
460
+ return { key, value: tokens.slice(1).join(" ") };
461
+ }
462
+
463
+ function describeConfig(cwd: string): string {
464
+ return `Default style (new sessions): ${readState(userStateFile()).active ?? "(none)"}\nStyle indicator: ${resolveIndicator(cwd)}`;
465
+ }
466
+
467
+ function applyConfigValue(key: ConfigKey, value: string, ctx: ExtensionContext, styles: Map<string, Style>): void {
468
+ const raw = value.trim();
469
+
470
+ if (key === "default") {
471
+ if (raw.length === 0) {
472
+ ctx.ui.notify(`Default style (new sessions): ${readState(userStateFile()).active ?? "(none)"}`, "info");
473
+ return;
474
+ }
475
+ if (OFF_WORDS[raw.toLowerCase()]) {
476
+ updateState(userStateFile(), { active: undefined });
477
+ ctx.ui.notify("Default style cleared. New sessions start with no style.", "info");
478
+ renderIndicator(ctx, resolveActiveStyle(ctx.cwd, styles));
479
+ return;
480
+ }
481
+ const name = resolveStyleName(raw, styles.keys());
482
+ if (!name) {
483
+ ctx.ui.notify(`Unknown style "${raw}". Available: ${[...styles.keys()].sort().join(", ") || "(none)"}`, "error");
484
+ return;
485
+ }
486
+ updateState(userStateFile(), { active: name });
487
+ ctx.ui.notify(`Default style → "${name}" for every new session and project.`, "info");
488
+ renderIndicator(ctx, resolveActiveStyle(ctx.cwd, styles));
489
+ return;
490
+ }
491
+
492
+ if (raw.length === 0) {
493
+ ctx.ui.notify(`Style indicator: ${resolveIndicator(ctx.cwd)}`, "info");
494
+ return;
495
+ }
496
+ if (!isIndicatorMode(raw)) {
497
+ ctx.ui.notify(`Indicator must be one of: ${INDICATOR_MODES.join(", ")}`, "error");
498
+ return;
499
+ }
500
+ updateState(userStateFile(), { indicator: raw });
501
+ ctx.ui.notify(`Style indicator → ${raw}`, "info");
502
+ renderIndicator(ctx, resolveActiveStyle(ctx.cwd, styles));
503
+ }
504
+
505
+ // Interactive path: two dialogs, then a summary. Falls back to printing when
506
+ // the run has no dialog-capable UI (print/json modes).
507
+ async function openConfigDialogs(ctx: ExtensionContext, styles: Map<string, Style>): Promise<void> {
508
+ if (typeof ctx.ui.select !== "function") {
509
+ ctx.ui.notify(describeConfig(ctx.cwd), "info");
510
+ return;
511
+ }
512
+
513
+ const current = readState(userStateFile()).active ?? "(none)";
514
+ const chosen = await ctx.ui.select(`Default output style for new sessions (now: ${current})`, [
515
+ "(keep current)",
516
+ "(none)",
517
+ ...[...styles.keys()].sort(),
518
+ ]);
519
+ if (chosen !== undefined && chosen !== "(keep current)") {
520
+ updateState(userStateFile(), { active: chosen === "(none)" ? undefined : chosen });
521
+ }
522
+
523
+ const mode = await ctx.ui.select(`Style indicator (now: ${resolveIndicator(ctx.cwd)})`, [
524
+ "(keep current)",
525
+ ...INDICATOR_MODES,
526
+ ]);
527
+ if (mode !== undefined && mode !== "(keep current)" && isIndicatorMode(mode)) {
528
+ updateState(userStateFile(), { indicator: mode });
529
+ }
530
+
531
+ renderIndicator(ctx, resolveActiveStyle(ctx.cwd, styles));
532
+ ctx.ui.notify(`Saved.\n${describeConfig(ctx.cwd)}`, "info");
533
+ }
534
+
399
535
  export default function outputStyles(pi: ExtensionAPI): void {
400
536
  pi.on("session_start", (_event, ctx) => {
401
- refreshStatus(ctx, resolveActiveStyle(ctx.cwd));
537
+ renderIndicator(ctx, resolveActiveStyle(ctx.cwd));
402
538
  if (started || !ctx.hasUI) return;
403
539
  started = true;
404
540
  startHintPoller(ctx);
@@ -412,14 +548,14 @@ export default function outputStyles(pi: ExtensionAPI): void {
412
548
  try {
413
549
  const style = resolveActiveStyle(ctx.cwd);
414
550
  if (!style) {
415
- refreshStatus(ctx, null);
551
+ renderIndicator(ctx, null);
416
552
  return;
417
553
  }
418
554
  // Apply first; only reflect the style in the status line once the prompt
419
555
  // was actually augmented, so a swallowed throw never advertises a style
420
556
  // the turn did not apply.
421
557
  const systemPrompt = applyStyle(event.systemPrompt ?? "", style);
422
- refreshStatus(ctx, style);
558
+ renderIndicator(ctx, style);
423
559
  return { systemPrompt };
424
560
  } catch {
425
561
  return; // never fail a turn over a styling concern
@@ -430,10 +566,21 @@ export default function outputStyles(pi: ExtensionAPI): void {
430
566
  description:
431
567
  "Select an output style, or ask the agent to review, rewrite, or create one. Usage: /output-style <name|off|what you want> [--save] [--project]",
432
568
  getArgumentCompletions: argumentPrefix => styleCompletions(argumentPrefix, process.cwd()),
433
- handler: (args, ctx) => {
569
+ handler: async (args, ctx) => {
434
570
  const styles = discoverStyles(styleDirs(ctx.cwd));
435
571
  const route = routeStyleCommand(args, styles.keys());
436
572
 
573
+ if (route.kind === "config") {
574
+ const parsed = parseConfigArgs(route.args);
575
+ if (parsed === null) {
576
+ ctx.ui.notify("Config keys: default <style|off>, indicator <status|widget|off>", "error");
577
+ return;
578
+ }
579
+ if (parsed.key === null) await openConfigDialogs(ctx, styles);
580
+ else applyConfigValue(parsed.key, parsed.value, ctx, styles);
581
+ return;
582
+ }
583
+
437
584
  if (route.kind === "task") {
438
585
  let task: string;
439
586
  try {
@@ -478,16 +625,16 @@ export default function outputStyles(pi: ExtensionAPI): void {
478
625
  let offScope = "this session";
479
626
  try {
480
627
  if (persist === "user") {
481
- writeState(userStateFile(), {});
628
+ updateState(userStateFile(), { active: undefined });
482
629
  offScope = "cleared · user default";
483
630
  } else if (persist === "project") {
484
- writeState(projectStateFile(ctx.cwd), {});
631
+ updateState(projectStateFile(ctx.cwd), { active: undefined });
485
632
  offScope = "cleared · project default";
486
633
  }
487
634
  } catch (err) {
488
635
  ctx.ui.notify(`Cleared for this session, but updating the saved default failed: ${String(err)}`, "warning");
489
636
  }
490
- refreshStatus(ctx, null);
637
+ renderIndicator(ctx, null);
491
638
  ctx.ui.notify(`Output style off (${offScope}).`, "info");
492
639
  return;
493
640
  }
@@ -500,16 +647,16 @@ export default function outputStyles(pi: ExtensionAPI): void {
500
647
  let scope = "this session";
501
648
  try {
502
649
  if (persist === "user") {
503
- writeState(userStateFile(), { active: name });
650
+ updateState(userStateFile(), { active: name });
504
651
  scope = "saved · user default";
505
652
  } else if (persist === "project") {
506
- writeState(projectStateFile(ctx.cwd), { active: name });
653
+ updateState(projectStateFile(ctx.cwd), { active: name });
507
654
  scope = "saved · project default";
508
655
  }
509
656
  } catch (err) {
510
657
  ctx.ui.notify(`Applied for this session, but saving failed: ${String(err)}`, "warning");
511
658
  }
512
- refreshStatus(ctx, styles.get(name) ?? null);
659
+ renderIndicator(ctx, styles.get(name) ?? null);
513
660
  ctx.ui.notify(`Output style → "${name}" (${scope}).`, "info");
514
661
  },
515
662
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-native-output-styles",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Named, swappable system-prompt styles for Pi — live /style switcher with native .pi/ directory support.",
5
5
  "keywords": [
6
6
  "pi-package",