@owlburtoe/pi-cc-tools 1.0.0 → 1.0.1

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
@@ -1,13 +1,4 @@
1
- # pi-cc-tools
2
-
3
- ## Install
4
-
5
- ```bash
6
- pi install npm:@owlburtoe/pi-cc-tools
7
- ```
8
-
9
- Run `/reload` or restart Pi after installation.
10
-
1
+ # pi-claude-style-tools
11
2
 
12
3
  Claude Code inspired tool rendering for Pi — Shiki-powered diffs, status dots, branch connectors, file icons, and configurable output modes.
13
4
 
@@ -20,6 +11,7 @@ Claude Code inspired tool rendering for Pi — Shiki-powered diffs, status dots,
20
11
  - **Diff stat bar** with colored add/remove summary and hunk metadata
21
12
  - **Progressive collapsed diff hints** that shorten on narrow terminals
22
13
  - **Thinking labels** during streaming and final messages, with context sanitization
14
+ - **Claude-style transcript grammar controls** for assistant/thinking prefixes, message spacing, hidden thinking labels, and active working tips
23
15
  - **MCP-aware rendering** with hidden, summary, and preview modes
24
16
  - **Configurable output modes** for read, search, bash, and MCP results
25
17
  - **Transparent tool backgrounds** in `transparent` or `border` mode
@@ -45,7 +37,14 @@ Set in `.pi/settings.json` or `~/.pi/settings.json`:
45
37
  "diffTheme": "github-dark",
46
38
  "spinnerColor": "borderAccent",
47
39
  "spinnerVerbs": ["Reviewing", "Polishing"],
48
- "spinnerVerbMode": "append"
40
+ "spinnerVerbMode": "append",
41
+ "messageStyle": "claude",
42
+ "assistantPrefix": "●",
43
+ "thinkingPrefix": "✻",
44
+ "messageSpacing": "comfortable",
45
+ "workingTipEnabled": true,
46
+ "workingTipText": "Run /install-github-app to tag @claude right from your GitHub issues and PRs",
47
+ "hiddenThinkingLabel": "Pondering..."
49
48
  }
50
49
  ```
51
50
 
@@ -100,6 +99,25 @@ The spinner glyph and verb text (e.g. `✻ Cooking…`) share `borderAccent` by
100
99
 
101
100
  Color selections are persisted as `spinnerColor` / `spinnerStatusColor` in `~/.pi/settings.json` and applied on the next spinner tick. Older `spinnerVerbColor` settings still work as a backward-compatible alias. Custom verbs are persisted as `spinnerVerbs` and `spinnerVerbMode`; they are picked at the next turn start. `/cc-spinner verbs ...` writes user settings in `~/.pi/settings.json`; project-level custom verbs can be set manually in `.pi/settings.json`. When both project and user spinner settings exist, the spinner reader applies project settings first and user settings second.
102
101
 
102
+ #### Tune assistant/thinking transcript chrome with `/cc-message`
103
+
104
+ ```text
105
+ /cc-message # show current message chrome settings
106
+ /cc-message style claude # screenshot-style transcript rhythm
107
+ /cc-message style classic # older package spacing/prefix behavior
108
+ /cc-message spacing comfortable # keep one blank line between paragraphs
109
+ /cc-message spacing compact # remove blank lines inside assistant/thinking blocks
110
+ /cc-message assistant-prefix ● # set assistant paragraph prefix
111
+ /cc-message thinking-prefix ✻ # set visible thinking prefix
112
+ /cc-message hidden-thinking-label Pondering...
113
+ /cc-message tip on # show active working tip line
114
+ /cc-message tip off # hide active working tip line
115
+ /cc-message tip text Run /help for tips # set active working tip text
116
+ /cc-message reset # restore message chrome defaults
117
+ ```
118
+
119
+ `messageStyle: "claude"` trims leading/trailing blank render lines, collapses paragraph gaps, and aligns wrapped assistant/thinking lines under the message body, matching Claude Code's sparse transcript grammar. `messageStyle: "classic"` keeps the previous package behavior. The active working tip is rendered as a subordinate `└─ Tip:` line under the spinner when supported by the Pi loader.
120
+
103
121
  ### Tool background modes
104
122
 
105
123
  | Value | Behavior |
@@ -14,5 +14,12 @@
14
14
  "diffColors": {},
15
15
  "spinnerColor": "borderAccent",
16
16
  "spinnerVerbs": ["Reviewing", "Polishing"],
17
- "spinnerVerbMode": "append"
17
+ "spinnerVerbMode": "append",
18
+ "messageStyle": "claude",
19
+ "assistantPrefix": "●",
20
+ "thinkingPrefix": "✻",
21
+ "messageSpacing": "comfortable",
22
+ "workingTipEnabled": true,
23
+ "workingTipText": "Run /install-github-app to tag @claude right from your GitHub issues and PRs",
24
+ "hiddenThinkingLabel": "Pondering..."
18
25
  }
@@ -9,7 +9,7 @@ import type {
9
9
  GrepToolDetails,
10
10
  ReadToolDetails,
11
11
  Theme,
12
- } from "@earendil-works/pi-coding-agent";
12
+ } from "@mariozechner/pi-coding-agent";
13
13
  import {
14
14
  AssistantMessageComponent,
15
15
  CustomMessageComponent,
@@ -22,7 +22,7 @@ import {
22
22
  createLsTool,
23
23
  createReadTool,
24
24
  createWriteTool,
25
- } from "@earendil-works/pi-coding-agent";
25
+ } from "@mariozechner/pi-coding-agent";
26
26
  import {
27
27
  Box,
28
28
  Container,
@@ -36,11 +36,21 @@ import {
36
36
  truncateToWidth,
37
37
  visibleWidth,
38
38
  wrapTextWithAnsi,
39
- } from "@earendil-works/pi-tui";
39
+ } from "@mariozechner/pi-tui";
40
40
 
41
41
  import * as Diff from "diff";
42
42
  import type { BundledLanguage, BundledTheme } from "shiki";
43
43
 
44
+ import {
45
+ DEFAULT_HIDDEN_THINKING_LABEL,
46
+ formatTranscriptLines,
47
+ resolveMessageChromeSettings,
48
+ sanitizeMessagePrefix,
49
+ type MessageChromeSettings,
50
+ type MessageSpacing,
51
+ type MessageStyle,
52
+ } from "./message-chrome.ts";
53
+
44
54
  const RESET = "\x1b[0m";
45
55
  const TRANSPARENT_BG = "\x1b[49m";
46
56
  const TRANSPARENT_RESET = `${RESET}${TRANSPARENT_BG}`;
@@ -105,6 +115,20 @@ interface SettingsFile {
105
115
  spinnerVerbs?: string[];
106
116
  /** Whether custom spinnerVerbs append to or replace the default verb list. */
107
117
  spinnerVerbMode?: SpinnerVerbMode;
118
+ /** Assistant/thinking transcript chrome style. "claude" trims spacing like Claude Code; "classic" keeps the older package rhythm. */
119
+ messageStyle?: MessageStyle;
120
+ /** Prefix glyph/text for assistant paragraphs. Defaults to Claude Code-style "●". */
121
+ assistantPrefix?: string;
122
+ /** Prefix glyph/text for visible thinking paragraphs. Defaults to "✻". */
123
+ thinkingPrefix?: string;
124
+ /** Blank-line normalization in assistant/thinking transcript blocks. */
125
+ messageSpacing?: MessageSpacing;
126
+ /** Whether the active working spinner may show a subordinate Claude-style tip line. */
127
+ workingTipEnabled?: boolean;
128
+ /** Custom subordinate tip line text for the active working spinner. */
129
+ workingTipText?: string;
130
+ /** Label shown when thinking blocks are hidden by the Pi UI. */
131
+ hiddenThinkingLabel?: string;
108
132
  }
109
133
 
110
134
  let _settingsCache: { value: SettingsFile; timestamp: number } | null = null;
@@ -144,7 +168,13 @@ function bustSpinnerSettingsCache(): void {
144
168
  (globalThis as any)[SPINNER_BUST_KEY] = current + 1;
145
169
  }
146
170
 
171
+ let _mergedSettingsCache: { value: SettingsFile; timestamp: number } | null = null;
172
+
147
173
  function readMergedSettings(): SettingsFile {
174
+ const now = Date.now();
175
+ if (_mergedSettingsCache && now - _mergedSettingsCache.timestamp < SETTINGS_CACHE_TTL_MS) {
176
+ return _mergedSettingsCache.value;
177
+ }
148
178
  const paths = [`${process.cwd()}/.pi/settings.json`, `${process.env.HOME ?? ""}/.pi/settings.json`];
149
179
  const merged: SettingsFile = {};
150
180
  for (const path of paths) {
@@ -156,9 +186,20 @@ function readMergedSettings(): SettingsFile {
156
186
  // ignore invalid settings files
157
187
  }
158
188
  }
189
+ _mergedSettingsCache = { value: merged, timestamp: now };
159
190
  return merged;
160
191
  }
161
192
 
193
+ function getMessageChromeSettings(): MessageChromeSettings {
194
+ return resolveMessageChromeSettings(readMergedSettings());
195
+ }
196
+
197
+ function applyHiddenThinkingLabel(ctx: any): void {
198
+ if (!ctx?.hasUI || typeof ctx.ui?.setHiddenThinkingLabel !== "function") return;
199
+ const label = getMessageChromeSettings().hiddenThinkingLabel;
200
+ ctx.ui.setHiddenThinkingLabel(label || DEFAULT_HIDDEN_THINKING_LABEL);
201
+ }
202
+
162
203
  const MAX_CUSTOM_SPINNER_VERBS = 200;
163
204
  const MAX_SPINNER_VERB_LENGTH = 48;
164
205
  const ANSI_ESCAPE_SEQUENCE_RE = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g;
@@ -196,6 +237,7 @@ function getSpinnerVerbMode(settings: SettingsFile): SpinnerVerbMode {
196
237
 
197
238
  function writeSettingsKey(key: string, value: unknown): void {
198
239
  _settingsCache = null; // invalidate cache on write
240
+ _mergedSettingsCache = null;
199
241
  const home = process.env.HOME ?? "";
200
242
  if (!home) return;
201
243
  const dir = `${home}/.pi`;
@@ -474,9 +516,43 @@ function appendWorkedDurationLine(message: any, durationMs: number): void {
474
516
  lastText.text = `${text.trimEnd()}\n\n${inlineWorkedDurationText(durationMs)}`;
475
517
  }
476
518
 
519
+ function messageChromeCacheKey(settings: MessageChromeSettings, kind: "assistant" | "thinking"): string {
520
+ return [
521
+ settings.messageStyle,
522
+ settings.messageSpacing,
523
+ kind === "assistant" ? settings.assistantPrefix : settings.thinkingPrefix,
524
+ ].join(":");
525
+ }
526
+
527
+ function renderClassicPrefixedLines(lines: string[], prefix: string, normalizeChecks = true): string[] {
528
+ let prefixPlaced = false;
529
+ return lines.map((line: string) => {
530
+ const displayLine = normalizeChecks ? normalizeLeadingCheckGlyph(line) : line;
531
+ if (!prefixPlaced && stripAnsi(displayLine).trim()) {
532
+ prefixPlaced = true;
533
+ return ` ${prefix} ${displayLine}`;
534
+ }
535
+ return ` ${displayLine}`;
536
+ });
537
+ }
538
+
539
+ function colorFirstTranscriptPrefix(lines: string[], prefixGlyph: string, coloredPrefixGlyph: string): string[] {
540
+ const plainPrefix = ` ${prefixGlyph} `;
541
+ const coloredPrefix = ` ${coloredPrefixGlyph} `;
542
+ let replaced = false;
543
+ return lines.map((line) => {
544
+ if (!replaced && line.startsWith(plainPrefix)) {
545
+ replaced = true;
546
+ return `${coloredPrefix}${line.slice(plainPrefix.length)}`;
547
+ }
548
+ return line;
549
+ });
550
+ }
551
+
477
552
  class DottedParagraph {
478
553
  private md: InstanceType<typeof Markdown>;
479
554
  private cachedWidth?: number;
555
+ private cachedChromeKey?: string;
480
556
  private cachedLines?: string[];
481
557
 
482
558
  constructor(text: string, markdownTheme: ConstructorParameters<typeof Markdown>[3]) {
@@ -485,31 +561,35 @@ class DottedParagraph {
485
561
 
486
562
  invalidate(): void {
487
563
  this.cachedWidth = undefined;
564
+ this.cachedChromeKey = undefined;
488
565
  this.cachedLines = undefined;
489
566
  this.md.invalidate();
490
567
  }
491
568
 
492
569
  render(width: number): string[] {
493
- if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
494
- // " ● " = 1 margin + dot + space = 3 visible chars
495
- const PREFIX_W = 3;
496
- if (width <= PREFIX_W) {
570
+ const settings = getMessageChromeSettings();
571
+ const chromeKey = messageChromeCacheKey(settings, "assistant");
572
+ if (this.cachedLines && this.cachedWidth === width && this.cachedChromeKey === chromeKey) return this.cachedLines;
573
+ const prefixGlyph = settings.messageStyle === "classic" ? "●" : settings.assistantPrefix;
574
+ const prefixWidth = Math.max(1, visibleWidth(` ${prefixGlyph} `));
575
+ if (width <= prefixWidth) {
497
576
  this.cachedWidth = width;
498
- this.cachedLines = [" ● "];
577
+ this.cachedChromeKey = chromeKey;
578
+ this.cachedLines = [` ${prefixGlyph} `];
499
579
  return this.cachedLines;
500
580
  }
501
- const lines = sanitizeRenderedTextBlockLines(this.md.render(width - PREFIX_W));
581
+ const lines = sanitizeRenderedTextBlockLines(this.md.render(width - prefixWidth));
502
582
  const looksLikeTaskStatus = lines.some((line) => /\b(?:transcript:|No output\.|Wrapped up)/.test(stripAnsi(line)));
503
- const displayLines = looksLikeTaskStatus ? lines.map(normalizeLeadingCheckGlyph) : lines;
504
- let dotPlaced = false;
505
- const rendered = displayLines.map((line: string) => {
506
- if (!dotPlaced && stripAnsi(line).trim()) {
507
- dotPlaced = true;
508
- return ` ● ${line}`;
509
- }
510
- return ` ${line}`;
511
- });
583
+ const rendered = settings.messageStyle === "classic"
584
+ ? renderClassicPrefixedLines(lines, "●", looksLikeTaskStatus)
585
+ : formatTranscriptLines(lines, {
586
+ prefix: settings.assistantPrefix,
587
+ spacing: settings.messageSpacing,
588
+ normalizeChecks: looksLikeTaskStatus,
589
+ visibleWidth,
590
+ });
512
591
  this.cachedWidth = width;
592
+ this.cachedChromeKey = chromeKey;
513
593
  this.cachedLines = rendered;
514
594
  return rendered;
515
595
  }
@@ -518,6 +598,7 @@ class DottedParagraph {
518
598
  class ThinkingParagraph {
519
599
  private md: InstanceType<typeof Markdown>;
520
600
  private cachedWidth?: number;
601
+ private cachedChromeKey?: string;
521
602
  private cachedLines?: string[];
522
603
 
523
604
  constructor(
@@ -559,30 +640,39 @@ class ThinkingParagraph {
559
640
 
560
641
  invalidate(): void {
561
642
  this.cachedWidth = undefined;
643
+ this.cachedChromeKey = undefined;
562
644
  this.cachedLines = undefined;
563
645
  this.md.invalidate();
564
646
  }
565
647
 
566
648
  render(width: number): string[] {
567
- if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
568
- // " ✻ " = 1 margin + symbol + space = 3 visible chars
569
- const PREFIX_W = 3;
570
- const prefix = `${WORKED_LINE_FG}✻${RESET}`;
571
- if (width <= PREFIX_W) {
649
+ const settings = getMessageChromeSettings();
650
+ const chromeKey = messageChromeCacheKey(settings, "thinking");
651
+ if (this.cachedLines && this.cachedWidth === width && this.cachedChromeKey === chromeKey) return this.cachedLines;
652
+ const prefixGlyph = settings.messageStyle === "classic" ? "✻" : settings.thinkingPrefix;
653
+ const coloredPrefixGlyph = `${WORKED_LINE_FG}${prefixGlyph}${RESET}`;
654
+ const prefixWidth = Math.max(1, visibleWidth(` ${prefixGlyph} `));
655
+ if (width <= prefixWidth) {
572
656
  this.cachedWidth = width;
573
- this.cachedLines = [` ${prefix} `];
657
+ this.cachedChromeKey = chromeKey;
658
+ this.cachedLines = [` ${coloredPrefixGlyph} `];
574
659
  return this.cachedLines;
575
660
  }
576
- const lines = sanitizeRenderedTextBlockLines(this.md.render(width - PREFIX_W));
577
- let symbolPlaced = false;
578
- const rendered = lines.map((line: string) => {
579
- if (!symbolPlaced && stripAnsi(line).trim()) {
580
- symbolPlaced = true;
581
- return ` ${prefix} ${line}`;
582
- }
583
- return ` ${line}`;
584
- });
661
+ const lines = sanitizeRenderedTextBlockLines(this.md.render(width - prefixWidth));
662
+ const rendered = settings.messageStyle === "classic"
663
+ ? renderClassicPrefixedLines(lines, coloredPrefixGlyph, false)
664
+ : colorFirstTranscriptPrefix(
665
+ formatTranscriptLines(lines, {
666
+ prefix: prefixGlyph,
667
+ spacing: settings.messageSpacing,
668
+ normalizeChecks: false,
669
+ visibleWidth,
670
+ }),
671
+ prefixGlyph,
672
+ coloredPrefixGlyph,
673
+ );
585
674
  this.cachedWidth = width;
675
+ this.cachedChromeKey = chromeKey;
586
676
  this.cachedLines = rendered;
587
677
  return rendered;
588
678
  }
@@ -2641,13 +2731,14 @@ function stripThinkingPresentationArtifacts(text: string): string {
2641
2731
  }
2642
2732
 
2643
2733
  function prefixThinkingLine(text: string, _theme: Theme | undefined): string {
2644
- if (!ANSI_PRESENT_RE.test(text) && text.startsWith("Thinking: ") && !/^Thinking:\s*thinking:\s*/i.test(text)) {
2734
+ const settings = getMessageChromeSettings();
2735
+ if (settings.messageStyle === "classic" && !ANSI_PRESENT_RE.test(text) && text.startsWith("Thinking: ") && !/^Thinking:\s*thinking:\s*/i.test(text)) {
2645
2736
  return text;
2646
2737
  }
2647
2738
  const normalized = stripThinkingPresentationArtifacts(text).trim();
2648
2739
  if (!normalized) return text;
2649
2740
  // Plain text — no ANSI colors, no theme. The ThinkingParagraph handles styling.
2650
- return `Thinking: ${normalized}`;
2741
+ return settings.messageStyle === "classic" ? `Thinking: ${normalized}` : normalized;
2651
2742
  }
2652
2743
 
2653
2744
  function registerThinkingLabels(pi: ExtensionAPI): void {
@@ -3920,16 +4011,167 @@ export default function (pi: ExtensionAPI) {
3920
4011
  },
3921
4012
  });
3922
4013
 
4014
+ const MESSAGE_COMMANDS = ["style", "spacing", "assistant-prefix", "thinking-prefix", "tip", "hidden-thinking-label", "reset", "status"] as const;
4015
+ pi.registerCommand("cc-message", {
4016
+ description: "Configure Claude-style assistant/thinking transcript chrome",
4017
+ getArgumentCompletions(prefix: string) {
4018
+ const parts = prefix.split(/\s+/);
4019
+ if (parts.length <= 1) {
4020
+ const first = (parts[0] ?? "").toLowerCase();
4021
+ return MESSAGE_COMMANDS
4022
+ .filter((c) => c.startsWith(first))
4023
+ .map((c) => ({
4024
+ value: c,
4025
+ label: c,
4026
+ description:
4027
+ c === "style" ? "Set transcript style: claude or classic"
4028
+ : c === "spacing" ? "Set blank-line rhythm: compact or comfortable"
4029
+ : c === "assistant-prefix" ? "Set assistant paragraph prefix glyph/text"
4030
+ : c === "thinking-prefix" ? "Set visible thinking prefix glyph/text"
4031
+ : c === "tip" ? "Enable, disable, or customize active working tip line"
4032
+ : c === "hidden-thinking-label" ? "Set label shown when thinking is hidden"
4033
+ : c === "reset" ? "Reset message chrome settings"
4034
+ : "Show current message chrome settings",
4035
+ }));
4036
+ }
4037
+ if (parts[0] === "style") {
4038
+ const valuePrefix = (parts[1] ?? "").toLowerCase();
4039
+ return ["claude", "classic"]
4040
+ .filter((v) => v.startsWith(valuePrefix))
4041
+ .map((v) => ({ value: `style ${v}`, label: v, description: v === "claude" ? "Screenshot-style transcript rhythm" : "Older package spacing/prefix behavior" }));
4042
+ }
4043
+ if (parts[0] === "spacing") {
4044
+ const valuePrefix = (parts[1] ?? "").toLowerCase();
4045
+ return ["comfortable", "compact"]
4046
+ .filter((v) => v.startsWith(valuePrefix))
4047
+ .map((v) => ({ value: `spacing ${v}`, label: v, description: v === "comfortable" ? "Preserve one blank line between paragraphs" : "Remove blank lines inside assistant/thinking blocks" }));
4048
+ }
4049
+ if (parts[0] === "tip") {
4050
+ const valuePrefix = (parts[1] ?? "").toLowerCase();
4051
+ return ["on", "off", "text", "reset"]
4052
+ .filter((v) => v.startsWith(valuePrefix))
4053
+ .map((v) => ({ value: `tip ${v}`, label: v, description: v === "text" ? "Set custom active working tip text" : `${v} working tip line` }));
4054
+ }
4055
+ return [];
4056
+ },
4057
+ async handler(args: string, ctx: any) {
4058
+ const rawArgs = args.trim();
4059
+ const parts = rawArgs.split(/\s+/).filter((p: string) => p.length > 0);
4060
+ const sub = (parts[0] ?? "status").toLowerCase();
4061
+ const current = getMessageChromeSettings();
4062
+ const notifyStatus = () => {
4063
+ if (!ctx.hasUI) return;
4064
+ ctx.ui.notify([
4065
+ `Message style: ${current.messageStyle}`,
4066
+ `Assistant prefix: ${current.assistantPrefix}`,
4067
+ `Thinking prefix: ${current.thinkingPrefix}`,
4068
+ `Spacing: ${current.messageSpacing}`,
4069
+ `Working tip: ${current.workingTipEnabled ? "on" : "off"}`,
4070
+ `Tip text: ${current.workingTipText}`,
4071
+ `Hidden thinking label: ${current.hiddenThinkingLabel}`,
4072
+ ].join("\n"), "info");
4073
+ };
4074
+
4075
+ if (!rawArgs || sub === "status") {
4076
+ notifyStatus();
4077
+ return;
4078
+ }
4079
+
4080
+ if (sub === "reset") {
4081
+ for (const key of ["messageStyle", "assistantPrefix", "thinkingPrefix", "messageSpacing", "workingTipEnabled", "workingTipText", "hiddenThinkingLabel"]) {
4082
+ writeSettingsKey(key, undefined);
4083
+ }
4084
+ bustSpinnerSettingsCache();
4085
+ applyHiddenThinkingLabel(ctx);
4086
+ if (ctx.hasUI) ctx.ui.notify("Message chrome reset to Claude-style defaults", "info");
4087
+ return;
4088
+ }
4089
+
4090
+ if (sub === "style") {
4091
+ const value = (parts[1] ?? "").toLowerCase();
4092
+ if (value !== "claude" && value !== "classic") {
4093
+ if (ctx.hasUI) ctx.ui.notify("Usage: /cc-message style claude|classic", "error");
4094
+ return;
4095
+ }
4096
+ writeSettingsKey("messageStyle", value);
4097
+ writeSettingsKey("workingTipEnabled", value === "classic" ? false : undefined);
4098
+ bustSpinnerSettingsCache();
4099
+ if (ctx.hasUI) ctx.ui.notify(`Message style → ${value}`, "info");
4100
+ return;
4101
+ }
4102
+
4103
+ if (sub === "spacing") {
4104
+ const value = (parts[1] ?? "").toLowerCase();
4105
+ if (value !== "compact" && value !== "comfortable") {
4106
+ if (ctx.hasUI) ctx.ui.notify("Usage: /cc-message spacing compact|comfortable", "error");
4107
+ return;
4108
+ }
4109
+ writeSettingsKey("messageSpacing", value);
4110
+ if (ctx.hasUI) ctx.ui.notify(`Message spacing → ${value}`, "info");
4111
+ return;
4112
+ }
4113
+
4114
+ if (sub === "assistant-prefix" || sub === "thinking-prefix") {
4115
+ const rawValue = rawArgs.replace(new RegExp(`^${sub}\\s*`, "i"), "");
4116
+ const fallback = sub === "assistant-prefix" ? "●" : "✻";
4117
+ const value = sanitizeMessagePrefix(rawValue, fallback);
4118
+ writeSettingsKey(sub === "assistant-prefix" ? "assistantPrefix" : "thinkingPrefix", value);
4119
+ if (ctx.hasUI) ctx.ui.notify(`${sub === "assistant-prefix" ? "Assistant" : "Thinking"} prefix → ${value}`, "info");
4120
+ return;
4121
+ }
4122
+
4123
+ if (sub === "tip") {
4124
+ const action = (parts[1] ?? "").toLowerCase();
4125
+ if (action === "on" || action === "off") {
4126
+ writeSettingsKey("workingTipEnabled", action === "on");
4127
+ bustSpinnerSettingsCache();
4128
+ if (ctx.hasUI) ctx.ui.notify(`Working tip → ${action}`, "info");
4129
+ return;
4130
+ }
4131
+ if (action === "reset") {
4132
+ writeSettingsKey("workingTipEnabled", undefined);
4133
+ writeSettingsKey("workingTipText", undefined);
4134
+ bustSpinnerSettingsCache();
4135
+ if (ctx.hasUI) ctx.ui.notify("Working tip reset", "info");
4136
+ return;
4137
+ }
4138
+ if (action === "text") {
4139
+ const textArg = rawArgs.match(/^tip\s+text(?:\s+(.+))?$/i)?.[1] ?? "";
4140
+ const value = resolveMessageChromeSettings({ workingTipText: textArg }).workingTipText;
4141
+ writeSettingsKey("workingTipText", value);
4142
+ bustSpinnerSettingsCache();
4143
+ if (ctx.hasUI) ctx.ui.notify(`Working tip text → ${value}`, "info");
4144
+ return;
4145
+ }
4146
+ if (ctx.hasUI) ctx.ui.notify("Usage: /cc-message tip on|off|reset|text <text>", "error");
4147
+ return;
4148
+ }
4149
+
4150
+ if (sub === "hidden-thinking-label") {
4151
+ const textArg = rawArgs.match(/^hidden-thinking-label(?:\s+(.+))?$/i)?.[1] ?? "";
4152
+ const value = resolveMessageChromeSettings({ hiddenThinkingLabel: textArg }).hiddenThinkingLabel;
4153
+ writeSettingsKey("hiddenThinkingLabel", value);
4154
+ applyHiddenThinkingLabel(ctx);
4155
+ if (ctx.hasUI) ctx.ui.notify(`Hidden thinking label → ${value}`, "info");
4156
+ return;
4157
+ }
4158
+
4159
+ if (ctx.hasUI) ctx.ui.notify("Usage: /cc-message style|spacing|assistant-prefix|thinking-prefix|tip|hidden-thinking-label|reset|status", "error");
4160
+ },
4161
+ });
4162
+
3923
4163
  pi.on("session_start", async (_event, ctx) => {
3924
4164
  if (!ctx.hasUI) return;
3925
4165
  applyToolBackgroundMode(ctx.ui.theme);
3926
4166
  applyThemePaletteIfNeeded(ctx.ui.theme);
4167
+ applyHiddenThinkingLabel(ctx);
3927
4168
  });
3928
4169
 
3929
4170
  pi.on("turn_start", async (_event, ctx) => {
3930
4171
  if (!ctx.hasUI) return;
3931
4172
  applyToolBackgroundMode(ctx.ui.theme);
3932
4173
  applyThemePaletteIfNeeded(ctx.ui.theme);
4174
+ applyHiddenThinkingLabel(ctx);
3933
4175
  });
3934
4176
 
3935
4177
  const cwd = process.cwd();
@@ -0,0 +1,142 @@
1
+ export type MessageStyle = "classic" | "claude";
2
+ export type MessageSpacing = "compact" | "comfortable";
3
+
4
+ export interface MessageChromeInput {
5
+ messageStyle?: unknown;
6
+ assistantPrefix?: unknown;
7
+ thinkingPrefix?: unknown;
8
+ messageSpacing?: unknown;
9
+ workingTipEnabled?: unknown;
10
+ workingTipText?: unknown;
11
+ hiddenThinkingLabel?: unknown;
12
+ }
13
+
14
+ export interface MessageChromeSettings {
15
+ messageStyle: MessageStyle;
16
+ assistantPrefix: string;
17
+ thinkingPrefix: string;
18
+ messageSpacing: MessageSpacing;
19
+ workingTipEnabled: boolean;
20
+ workingTipText: string;
21
+ hiddenThinkingLabel: string;
22
+ }
23
+
24
+ export interface TranscriptLineOptions {
25
+ prefix: string;
26
+ spacing?: MessageSpacing;
27
+ normalizeChecks?: boolean;
28
+ visibleWidth?: (text: string) => number;
29
+ }
30
+
31
+ export const DEFAULT_WORKING_TIP_TEXT = "Run /install-github-app to tag @claude right from your GitHub issues and PRs";
32
+ export const DEFAULT_HIDDEN_THINKING_LABEL = "Pondering...";
33
+
34
+ const DEFAULT_MESSAGE_CHROME: MessageChromeSettings = {
35
+ messageStyle: "claude",
36
+ assistantPrefix: "●",
37
+ thinkingPrefix: "✻",
38
+ messageSpacing: "comfortable",
39
+ workingTipEnabled: true,
40
+ workingTipText: DEFAULT_WORKING_TIP_TEXT,
41
+ hiddenThinkingLabel: DEFAULT_HIDDEN_THINKING_LABEL,
42
+ };
43
+
44
+ const ANSI_ESCAPE_SEQUENCE_RE = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g;
45
+ const ANSI_SGR_RE = /\x1b\[[0-9;]*m/g;
46
+ const CONTROL_CHARS_RE = /[\u0000-\u001F\u007F-\u009F]/g;
47
+ const MAX_PREFIX_GRAPHEMES = 8;
48
+ const MAX_TIP_GRAPHEMES = 160;
49
+ const MAX_LABEL_GRAPHEMES = 80;
50
+
51
+ function stripAnsi(text: string): string {
52
+ return text.replace(ANSI_ESCAPE_SEQUENCE_RE, "").replace(ANSI_SGR_RE, "");
53
+ }
54
+
55
+ function stripControlChars(text: string): string {
56
+ return text.replace(CONTROL_CHARS_RE, "");
57
+ }
58
+
59
+ function takeGraphemes(text: string, max: number): string {
60
+ return Array.from(text).slice(0, max).join("");
61
+ }
62
+
63
+ function fallbackVisibleWidth(text: string): number {
64
+ return Array.from(stripAnsi(text)).length;
65
+ }
66
+
67
+ function isBlankLine(text: string): boolean {
68
+ return stripAnsi(text).trim().length === 0;
69
+ }
70
+
71
+ function sanitizeTextSetting(value: unknown, fallback: string, maxGraphemes: number): string {
72
+ if (typeof value !== "string") return fallback;
73
+ const cleaned = takeGraphemes(stripControlChars(stripAnsi(value)).trim(), maxGraphemes);
74
+ return cleaned || fallback;
75
+ }
76
+
77
+ export function sanitizeMessagePrefix(value: unknown, fallback: string): string {
78
+ return sanitizeTextSetting(value, fallback, MAX_PREFIX_GRAPHEMES);
79
+ }
80
+
81
+ export function resolveMessageChromeSettings(input: MessageChromeInput = {}): MessageChromeSettings {
82
+ const messageStyle: MessageStyle = input.messageStyle === "classic" ? "classic" : "claude";
83
+ const messageSpacing: MessageSpacing = input.messageSpacing === "compact" ? "compact" : "comfortable";
84
+ const defaultWorkingTipEnabled = messageStyle === "classic" ? false : DEFAULT_MESSAGE_CHROME.workingTipEnabled;
85
+ return {
86
+ messageStyle,
87
+ assistantPrefix: sanitizeMessagePrefix(input.assistantPrefix, DEFAULT_MESSAGE_CHROME.assistantPrefix),
88
+ thinkingPrefix: sanitizeMessagePrefix(input.thinkingPrefix, DEFAULT_MESSAGE_CHROME.thinkingPrefix),
89
+ messageSpacing,
90
+ workingTipEnabled: typeof input.workingTipEnabled === "boolean" ? input.workingTipEnabled : defaultWorkingTipEnabled,
91
+ workingTipText: sanitizeTextSetting(input.workingTipText, DEFAULT_MESSAGE_CHROME.workingTipText, MAX_TIP_GRAPHEMES),
92
+ hiddenThinkingLabel: sanitizeTextSetting(input.hiddenThinkingLabel, DEFAULT_MESSAGE_CHROME.hiddenThinkingLabel, MAX_LABEL_GRAPHEMES),
93
+ };
94
+ }
95
+
96
+ function normalizeTranscriptRenderedLines(lines: string[], spacing: MessageSpacing): string[] {
97
+ let start = 0;
98
+ while (start < lines.length && isBlankLine(lines[start])) start++;
99
+ let end = lines.length - 1;
100
+ while (end >= start && isBlankLine(lines[end])) end--;
101
+ if (start > end) return [];
102
+
103
+ const normalized: string[] = [];
104
+ let pendingBlank = false;
105
+ for (const line of lines.slice(start, end + 1)) {
106
+ if (isBlankLine(line)) {
107
+ if (spacing === "comfortable" && normalized.length > 0) pendingBlank = true;
108
+ continue;
109
+ }
110
+ if (pendingBlank) {
111
+ normalized.push("");
112
+ pendingBlank = false;
113
+ }
114
+ normalized.push(line);
115
+ }
116
+ return normalized;
117
+ }
118
+
119
+ function normalizeLeadingCheckGlyph(line: string): string {
120
+ return line.replace(/^((?:\x1b\[[0-9;]*m|[ \t])*)[✓✔](?=\s)/, "$1●");
121
+ }
122
+
123
+ export function formatTranscriptLines(lines: string[], options: TranscriptLineOptions): string[] {
124
+ const prefixGlyph = sanitizeMessagePrefix(options.prefix, DEFAULT_MESSAGE_CHROME.assistantPrefix);
125
+ const spacing = options.spacing ?? DEFAULT_MESSAGE_CHROME.messageSpacing;
126
+ const normalized = normalizeTranscriptRenderedLines(lines, spacing);
127
+ const prefix = ` ${prefixGlyph} `;
128
+ const measureWidth = options.visibleWidth ?? fallbackVisibleWidth;
129
+ const continuation = " ".repeat(Math.max(1, measureWidth(prefix)));
130
+
131
+ if (normalized.length === 0) return [prefix];
132
+
133
+ let prefixPlaced = false;
134
+ return normalized.map((line) => {
135
+ const displayLine = options.normalizeChecks === false ? line : normalizeLeadingCheckGlyph(line);
136
+ if (!prefixPlaced && !isBlankLine(displayLine)) {
137
+ prefixPlaced = true;
138
+ return `${prefix}${displayLine}`;
139
+ }
140
+ return `${continuation}${displayLine}`;
141
+ });
142
+ }
@@ -1,7 +1,9 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
 
3
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
- import { Loader } from "@earendil-works/pi-tui";
3
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
+ import { Loader } from "@mariozechner/pi-tui";
5
+
6
+ import { resolveMessageChromeSettings } from "./message-chrome.ts";
5
7
 
6
8
  // ---------------------------------------------------------------------------
7
9
  // Patch built-in Loader with Claude/OpenBrawd-style glyphs.
@@ -26,6 +28,8 @@ interface SpinnerSettings {
26
28
  verbColor: string;
27
29
  statusColor: string;
28
30
  verbs: readonly string[];
31
+ workingTipEnabled: boolean;
32
+ workingTipText: string;
29
33
  }
30
34
 
31
35
  let _spinnerSettingsCache: { value: SpinnerSettings; expires: number } | null = null;
@@ -96,6 +100,9 @@ function readSpinnerSettings(): SpinnerSettings {
96
100
  let statusColor = "muted";
97
101
  let customVerbs: string[] | null = null;
98
102
  let verbMode: SpinnerVerbMode = "append";
103
+ let messageStyle: string | undefined;
104
+ let workingTipEnabled: boolean | undefined;
105
+ let workingTipText: string | undefined;
99
106
  const paths = [`${process.cwd()}/.pi/settings.json`, `${process.env.HOME ?? ""}/.pi/settings.json`];
100
107
  for (const p of paths) {
101
108
  try {
@@ -108,10 +115,21 @@ function readSpinnerSettings(): SpinnerSettings {
108
115
  if (typeof raw.spinnerStatusColor === "string" && raw.spinnerStatusColor.length > 0) statusColor = raw.spinnerStatusColor;
109
116
  if (raw.spinnerVerbMode === "append" || raw.spinnerVerbMode === "replace") verbMode = raw.spinnerVerbMode;
110
117
  if (Array.isArray(raw.spinnerVerbs)) customVerbs = sanitizeSpinnerVerbs(raw.spinnerVerbs);
118
+ if (typeof raw.messageStyle === "string") messageStyle = raw.messageStyle;
119
+ if (typeof raw.workingTipEnabled === "boolean") workingTipEnabled = raw.workingTipEnabled;
120
+ if (typeof raw.workingTipText === "string") workingTipText = raw.workingTipText;
111
121
  }
112
122
  } catch { /* ignore */ }
113
123
  }
114
- const value: SpinnerSettings = { adaptive, verbColor, statusColor, verbs: resolveSpinnerVerbs(customVerbs, verbMode) };
124
+ const messageChrome = resolveMessageChromeSettings({ messageStyle, workingTipEnabled, workingTipText });
125
+ const value: SpinnerSettings = {
126
+ adaptive,
127
+ verbColor,
128
+ statusColor,
129
+ verbs: resolveSpinnerVerbs(customVerbs, verbMode),
130
+ workingTipEnabled: messageChrome.workingTipEnabled,
131
+ workingTipText: messageChrome.workingTipText,
132
+ };
115
133
  _spinnerSettingsCache = { value, expires: now + SPINNER_SETTINGS_TTL_MS };
116
134
  return value;
117
135
  }
@@ -549,6 +567,10 @@ export default function (pi: ExtensionAPI) {
549
567
  if (statusParts.length > 0) {
550
568
  message += statusText(` (${statusParts.join(" · ")})`);
551
569
  }
570
+ const { workingTipEnabled, workingTipText } = readSpinnerSettings();
571
+ if (workingTipEnabled && workingTipText) {
572
+ message += `\n${STATUS_DIM} └─ Tip: ${workingTipText}${RESET}`;
573
+ }
552
574
  return message;
553
575
  }
554
576
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlburtoe/pi-cc-tools",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Claude Code-style tool rows for pi with Ctrl+O image previews and consistent built-in, MCP, and custom tool rendering",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -14,7 +14,7 @@
14
14
  "diff"
15
15
  ],
16
16
  "license": "MIT",
17
- "author": "owlburtoe",
17
+ "author": "FammasMaz",
18
18
  "repository": {
19
19
  "type": "git",
20
20
  "url": "git+https://forgejo.owlburtoe.dev/owlburtoe/pi-claude-style-tools.git"
@@ -26,19 +26,22 @@
26
26
  "files": [
27
27
  "extensions/index.ts",
28
28
  "extensions/spinner.ts",
29
+ "extensions/message-chrome.ts",
29
30
  "config/config.example.json",
30
31
  "README.md"
31
32
  ],
32
33
  "scripts": {
33
- "bench:tools": "bun scripts/benchmark-tools.ts"
34
+ "bench:tools": "bun scripts/benchmark-tools.ts",
35
+ "test:message-chrome": "bun scripts/test-message-chrome.ts"
34
36
  },
35
37
  "dependencies": {
36
38
  "@shikijs/cli": "^4.0.2",
37
39
  "diff": "^7.0.0"
38
40
  },
39
41
  "peerDependencies": {
40
- "@earendil-works/pi-coding-agent": "*",
41
- "@earendil-works/pi-tui": "*"
42
+ "@mariozechner/pi-coding-agent": "*",
43
+ "@mariozechner/pi-tui": "*",
44
+ "@sinclair/typebox": "*"
42
45
  },
43
46
  "pi": {
44
47
  "extensions": [
@@ -47,10 +50,11 @@
47
50
  ]
48
51
  },
49
52
  "devDependencies": {
53
+ "@mariozechner/pi-coding-agent": "latest",
54
+ "@mariozechner/pi-tui": "latest",
55
+ "@sinclair/typebox": "latest",
50
56
  "@types/diff": "^7.0.2",
51
57
  "@types/node": "^25.6.0",
52
- "typescript": "^6.0.2",
53
- "@earendil-works/pi-coding-agent": "latest",
54
- "@earendil-works/pi-tui": "latest"
58
+ "typescript": "^6.0.2"
55
59
  }
56
60
  }