@tian.zuo/pi-beautify-style 0.2.0 → 0.3.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,13 @@
1
1
  # @tian.zuo/pi-beautify-style
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#201](https://github.com/TianZuo555/pi-extensions/pull/201) [`fad61ea`](https://github.com/TianZuo555/pi-extensions/commit/fad61ea8cd833d5efc22c2e171507414f87e179a) Thanks [@TianZuo555](https://github.com/TianZuo555)! - Use a leading `## Heading` (or `**Bold**` line) from the reasoning as the thinking card title on every provider, not only OpenAI Responses. The title shows while the reasoning streams and stays on the finished card (`Plan · Thought for 8s`). Headings quoted from tool output mid-reasoning are ignored.
8
+
9
+ - [#201](https://github.com/TianZuo555/pi-extensions/pull/201) [`fad61ea`](https://github.com/TianZuo555/pi-extensions/commit/fad61ea8cd833d5efc22c2e171507414f87e179a) Thanks [@TianZuo555](https://github.com/TianZuo555)! - Show the current reasoning step instead of `Thinking...` when the reasoning has no heading (DeepSeek, GLM, Kimi, and other `reasoning_content` models): `Step 3 · Check the CI config · 6s`. A step comes from an intent sentence (`Let me …`, `I'll …`, `让我…`), a lead such as `Plan:` or `Decision:`, or a rethink (`Actually, let me …`). Each label stays at least 800 ms, so fast thoughts do not flicker. The finished card keeps the last step and the count: `Check the CI config · Thought for 8s · 3 steps`.
10
+
3
11
  ## 0.2.0
4
12
 
5
13
  ### Minor Changes
package/README.md CHANGED
@@ -90,6 +90,13 @@ and restart Pi to change behavior.
90
90
  > summary title — that heading becomes the card title while it streams, and stays
91
91
  > on the finished card (`Plan · Thought for 8s`). A heading copied out of tool
92
92
  > output mid-reasoning is ignored.
93
+ >
94
+ > Raw reasoning with no heading (DeepSeek, GLM, Kimi, and other
95
+ > `reasoning_content` models) shows its current step instead of `Thinking...`:
96
+ > `Step 3 · Check the CI config · 6s`. A step comes from an intent sentence
97
+ > (`Let me …`, `I'll …`, `让我…`), a lead such as `Plan:` or `Decision:`, or a
98
+ > rethink (`Actually, let me …`). The finished card keeps the last step and the
99
+ > count: `Check the CI config · Thought for 8s · 3 steps`.
93
100
 
94
101
  > [!NOTE]
95
102
  > For Mermaid diagrams, set `markdown.mermaid` to `final` in
@@ -54,9 +54,11 @@ export type Config = {
54
54
  toolLabelClip: number;
55
55
  /**
56
56
  * Title the reasoning card from a heading the reasoning leads with: an OpenAI
57
- * Responses summary heading, or a leading `# Heading` / `**Bold**` line. Other
58
- * providers get no title because their reasoning carries no reliable heading.
59
- * The title stays on the finished card (`Plan · Thought for 8s`).
57
+ * Responses summary heading, or a leading `# Heading` / `**Bold**` line. Reasoning
58
+ * without a heading shows its current step while it streams (`Step 3 · Check the
59
+ * CI config`), taken from intent sentences such as `Let me …` or leads such as
60
+ * `Plan:`. The title stays on the finished card (`Plan · Thought for 8s`, or
61
+ * `Check the CI config · Thought for 8s · 3 steps`).
60
62
  */
61
63
  useSummaryTitlesAsThinkingTitle: boolean;
62
64
  /**
@@ -29,6 +29,12 @@ import {
29
29
  styleCompactThinkingText,
30
30
  } from "../renderer/compact-mode.ts";
31
31
  import { refreshMountedTranscript } from "../renderer/transcript-refresh.ts";
32
+ import {
33
+ formatStepCount,
34
+ formatThinkingStep,
35
+ parseThinkingSteps,
36
+ type ThinkingStep,
37
+ } from "./thinking-steps.ts";
32
38
  import {
33
39
  COMPACT_THINKING_OWNER,
34
40
  COMPACT_THINKING_PATCH_KEY,
@@ -49,6 +55,9 @@ export type CompactThinkingConfig = {
49
55
  * Use a title that the reasoning leads with — an OpenAI Responses summary
50
56
  * heading or a leading `# Heading` / `**Bold**` line — as the card heading
51
57
  * instead of the `Thinking...` label. The title stays on the finished card.
58
+ * Reasoning without a title shows its current step instead
59
+ * (`Step 3 · Check the CI config`); the finished card keeps the last step and
60
+ * the step count.
52
61
  */
53
62
  useSummaryTitlesAsThinkingTitle: boolean;
54
63
  /**
@@ -132,6 +141,8 @@ const config: CompactThinkingConfig = {
132
141
  };
133
142
 
134
143
  const DURATION_ENTRY_TYPE = "compact-thinking-duration";
144
+ /** A live step label stays at least this long before the next one replaces it. */
145
+ const STEP_LABEL_MIN_MS = 800;
135
146
 
136
147
  /** 当前激活 session 的只读查询委托(compact 渲染层使用)。 */
137
148
  type ThinkingDurationQuery = (messageTimestamp: number) => number | undefined;
@@ -690,6 +701,30 @@ function compactThinking(pi: ExtensionAPI) {
690
701
  let animationFrame = 0;
691
702
  let compactSummaryActive = false;
692
703
  let patchInstalled = true;
704
+ /** Live step label on screen per run (`timestamp:runStartIndex`), for the flicker hold. */
705
+ const shownSteps = new Map<string, { label: string; count: number; shownAt: number }>();
706
+ let stepMemo: { text: string; streaming: boolean; steps: ThinkingStep[] } | undefined;
707
+
708
+ function stepsOf(text: string, streaming: boolean): ThinkingStep[] {
709
+ if (stepMemo?.text !== text || stepMemo.streaming !== streaming) {
710
+ stepMemo = { text, streaming, steps: parseThinkingSteps(text, streaming) };
711
+ }
712
+ return stepMemo.steps;
713
+ }
714
+
715
+ /** Keep the shown step for STEP_LABEL_MIN_MS so fast thoughts do not flicker. */
716
+ function heldStep(key: string, label: string, count: number) {
717
+ const now = Date.now();
718
+ const shown = shownSteps.get(key);
719
+ if (shown && shown.label !== label && now - shown.shownAt < STEP_LABEL_MIN_MS) return shown;
720
+ if (shown?.label === label) {
721
+ shown.count = count;
722
+ return shown;
723
+ }
724
+ const next = { label, count, shownAt: now };
725
+ shownSteps.set(key, next);
726
+ return next;
727
+ }
693
728
 
694
729
  function thinkingStyle(text: string) {
695
730
  return styleCompactThinkingText(text, activeTheme);
@@ -792,30 +827,40 @@ function compactThinking(pi: ExtensionAPI) {
792
827
  (previous.type === "thinking" && previous.thinking.trim()),
793
828
  );
794
829
  const thinkingBlocks: string[] = [];
830
+ // Untrimmed text: a trailing newline proves the last sentence is complete.
831
+ const rawThinkingBlocks: string[] = [];
795
832
  let latestSummary: SummaryPart | undefined;
833
+ const responses = isOpenAiResponsesMessage(message);
796
834
  for (; i < message.content.length; i++) {
797
835
  const thinkingContent = message.content[i];
798
836
  if (thinkingContent.type !== "thinking") break;
799
837
  const thinking = thinkingContent.thinking.trim();
800
838
  if (!thinking) continue;
801
839
  thinkingBlocks.push(thinking);
840
+ rawThinkingBlocks.push(thinkingContent.thinking);
802
841
 
803
842
  if (config.useSummaryTitlesAsThinkingTitle) {
804
843
  const contentIsActive =
805
844
  activeThinking?.messageTimestamp === message.timestamp &&
806
845
  activeThinking.contentIndex === i;
846
+ // Raw reasoning: a block's leading title names the card and leaves that
847
+ // block's body. Every block of the run stays in the preview.
848
+ if (!responses) {
849
+ const leading = parseLeadingTitle(thinking, contentIsActive);
850
+ if (leading) {
851
+ latestSummary = leading;
852
+ thinkingBlocks[thinkingBlocks.length - 1] = leading.body;
853
+ }
854
+ continue;
855
+ }
807
856
  // During streaming, thinkingSignature may contain only the previously
808
857
  // completed summary parts. Prefer the live text so a newly arriving
809
858
  // title immediately replaces the old one instead of appearing in its
810
859
  // preview body. Once complete, the structured signature is canonical.
811
860
  // OpenAI Responses concatenates several summary parts in one block, so
812
- // only there does the newest title win; any other provider only gets a
813
- // title from the leading line of its reasoning.
861
+ // the newest title wins and its summary body is the preview.
814
862
  const titleFromText = (streaming: boolean) =>
815
- isOpenAiResponsesMessage(message)
816
- ? (parseLatestTitle(thinking, streaming) ??
817
- parseLeadingTitle(thinking, streaming))
818
- : parseLeadingTitle(thinking, streaming);
863
+ parseLatestTitle(thinking, streaming) ?? parseLeadingTitle(thinking, streaming);
819
864
  const titleFromSignature = getLatestOpenAiSummary(
820
865
  thinkingContent.thinkingSignature,
821
866
  );
@@ -842,8 +887,23 @@ function compactThinking(pi: ExtensionAPI) {
842
887
  const durationMs = active ? elapsedMs : completedMs;
843
888
  const durationText = durationMs === undefined ? undefined : formatThoughtDuration(durationMs);
844
889
 
890
+ const steps =
891
+ config.useSummaryTitlesAsThinkingTitle && !latestSummary
892
+ ? stepsOf(rawThinkingBlocks.join("\n\n"), active)
893
+ : [];
894
+ const lastStep = steps.at(-1);
895
+
845
896
  let heading: string;
846
- if (active && latestSummary) {
897
+ if (active && lastStep) {
898
+ const shown = heldStep(
899
+ `${message.timestamp}:${runStartIndex}`,
900
+ formatThinkingStep(lastStep),
901
+ steps.length,
902
+ );
903
+ heading =
904
+ animatedText(`Step ${shown.count} · ${shown.label}`, thinkingStyle, true) +
905
+ (durationText ? thinkingStyle(` · ${durationText}`) : "");
906
+ } else if (active && latestSummary) {
847
907
  heading =
848
908
  animatedText(latestSummary.title, summaryTitleStyle, true) +
849
909
  (durationText ? thinkingStyle(` · ${durationText}`) : "");
@@ -857,13 +917,17 @@ function compactThinking(pi: ExtensionAPI) {
857
917
  // A detected title stays in front of it, so the card keeps its identity
858
918
  // after the run. 无时长记录(中断流/旧会话/entry 缺失或索引错位)时不回退到
859
919
  // 加载文案 "Thinking...":改用标题或中性 "Thought"。
860
- const title = latestSummary?.title;
861
- const status = durationText ? `Thought for ${durationText}` : undefined;
862
- heading = thinkingStyle(
863
- title ? (status ? `${title} · ${status}` : title) : (status ?? "Thought"),
864
- );
920
+ // Without a title, the last step names the card and the step count follows.
921
+ const title = latestSummary?.title ?? (lastStep && formatThinkingStep(lastStep));
922
+ const parts = [
923
+ title,
924
+ durationText ? `Thought for ${durationText}` : undefined,
925
+ lastStep ? formatStepCount(steps.length) : undefined,
926
+ ].filter((part): part is string => Boolean(part));
927
+ heading = thinkingStyle(parts.length > 0 ? parts.join(" · ") : "Thought");
865
928
  }
866
- const previewSource = (latestSummary?.body ?? thinkingBlocks.join("\n\n")).trim();
929
+ const fullRun = thinkingBlocks.filter(Boolean).join("\n\n");
930
+ const previewSource = (responses ? (latestSummary?.body ?? fullRun) : fullRun).trim();
867
931
  self.contentContainer.addChild(
868
932
  new ThinkingPreviewBlock(
869
933
  heading,
@@ -968,6 +1032,7 @@ function compactThinking(pi: ExtensionAPI) {
968
1032
  startedAt: Date.now(),
969
1033
  };
970
1034
  streamingComponents.clear();
1035
+ shownSteps.clear();
971
1036
  animationFrame = 0;
972
1037
 
973
1038
  // Depending on event-listener order, Pi may have rendered the empty
@@ -1003,6 +1068,7 @@ function compactThinking(pi: ExtensionAPI) {
1003
1068
  } as DurationEntryData);
1004
1069
 
1005
1070
  activeThinking = undefined;
1071
+ shownSteps.clear();
1006
1072
  stopAnimationTimerIfIdle();
1007
1073
 
1008
1074
  const components = [...streamingComponents];
@@ -0,0 +1,123 @@
1
+ // Step labels for raw chain-of-thought reasoning (DeepSeek, GLM, Kimi, Claude, …).
2
+ // These providers stream no headings, so the card would only say `Thinking...`.
3
+ // The reasoning still narrates itself: one paragraph per thought, an intent
4
+ // sentence (`Let me check X.`) for the next action, a pivot word (`Wait`,
5
+ // `Actually`) for a rethink, and a labeled lead (`Plan:`, `Decision:`) for a
6
+ // milestone. Each paragraph gives at most one step.
7
+ import { visibleWidth } from "@earendil-works/pi-tui";
8
+
9
+ export type ThinkingStepKind = "intent" | "rethink" | "milestone";
10
+
11
+ export type ThinkingStep = {
12
+ kind: ThinkingStepKind;
13
+ label: string;
14
+ };
15
+
16
+ /** Max columns of a step label, so the duration after it stays visible. */
17
+ const MAX_STEP_WIDTH = 60;
18
+
19
+ const LEAD_WORDS =
20
+ "ok|okay|so|now|next|then|first|finally|good|great|alright|right|actually|wait|hmm+|but|and|also|instead|alternatively";
21
+ const INTENT_EN = new RegExp(
22
+ `^(?:(?:${LEAD_WORDS})[,.!:;—–-]*\\s+)*(?:let me|let's|let us|i'll|i will|i'm going to|i need to)\\s+(?:(?:also|quickly|first|now|just|then|next|still)\\s+)*(.+)$`,
23
+ "i",
24
+ );
25
+ const INTENT_ZH =
26
+ /^(?:好的?|那么|现在|接下来|然后|首先|最后|嗯|不过|其实)?[,,、\s]*(?:让我|我来|我先|我需要|我要|我将|下一步)[,,::\s]*(.+)$/;
27
+ const PIVOT_EN = /^(?:wait|actually|but wait|alternatively|hold on|on second thought|instead)\b/i;
28
+ const PIVOT_ZH = /^(?:不对|等等|其实|或者|换个思路)/;
29
+ const MILESTONE =
30
+ /^(plan|decision|conclusion|confirmed|root cause|key question|approach|goal|summary|fix|findings?|result|answer|计划|结论|决定|方案|原因|根因|目标)[ \t]*[::][ \t]*([\s\S]*)$/i;
31
+ const LIST_MARKER = /^\s*(?:\d+[.)]|[-*•])\s+/;
32
+ /** End of a complete sentence or line. A `.` counts only before whitespace, so `foo.ts` is not cut. */
33
+ const BOUNDARY = /[.!?](?=\s)|[。!?]|\n/g;
34
+
35
+ function completePart(text: string): string {
36
+ let end = 0;
37
+ for (const match of text.matchAll(BOUNDARY)) end = match.index + match[0].length;
38
+ return text.slice(0, end);
39
+ }
40
+
41
+ function sentences(paragraph: string): string[] {
42
+ return paragraph
43
+ .split("\n")
44
+ .flatMap((line) => line.split(/(?<=[.!?])\s+(?=\S)|(?<=[。!?])/))
45
+ .map((sentence) => sentence.trim())
46
+ .filter(Boolean);
47
+ }
48
+
49
+ function clean(text: string): string {
50
+ const plain = text
51
+ .replace(/\s+/g, " ")
52
+ .replace(/[\s.!?:;,。!?:;,…]+$/, "")
53
+ .trim();
54
+ if (visibleWidth(plain) <= MAX_STEP_WIDTH) return plain;
55
+ let out = "";
56
+ let width = 0;
57
+ for (const char of plain) {
58
+ const charWidth = visibleWidth(char);
59
+ if (width + charWidth > MAX_STEP_WIDTH - 1) break;
60
+ out += char;
61
+ width += charWidth;
62
+ }
63
+ return `${out.trimEnd()}…`;
64
+ }
65
+
66
+ function capitalize(text: string): string {
67
+ return text.charAt(0).toUpperCase() + text.slice(1);
68
+ }
69
+
70
+ function milestoneStep(paragraph: string): ThinkingStep | undefined {
71
+ const match = MILESTONE.exec(paragraph);
72
+ if (!match) return undefined;
73
+ const lead = capitalize(match[1]);
74
+ const first = match[2]
75
+ .split("\n")
76
+ .map((line) => line.replace(LIST_MARKER, "").trim())
77
+ .find(Boolean);
78
+ const detail = first ? clean(sentences(first)[0] ?? "") : "";
79
+ return { kind: "milestone", label: detail ? clean(`${lead}: ${detail}`) : lead };
80
+ }
81
+
82
+ function intentStep(paragraph: string): ThinkingStep | undefined {
83
+ const all = sentences(paragraph);
84
+ for (let i = all.length - 1; i >= 0; i--) {
85
+ const sentence = all[i];
86
+ const english = INTENT_EN.exec(sentence);
87
+ const action = english?.[1] ?? INTENT_ZH.exec(sentence)?.[1];
88
+ if (!action || /^know\b/i.test(action)) continue;
89
+ const label = clean(english ? capitalize(action) : action);
90
+ if (label.length < 3) continue;
91
+ const pivot = PIVOT_EN.test(sentence) || PIVOT_ZH.test(sentence);
92
+ return { kind: pivot ? "rethink" : "intent", label };
93
+ }
94
+ return undefined;
95
+ }
96
+
97
+ /**
98
+ * Steps found in reasoning text, oldest first. While the text streams, the
99
+ * unfinished last sentence is ignored, so a label never shows half a word.
100
+ */
101
+ export function parseThinkingSteps(text: string, streaming: boolean): ThinkingStep[] {
102
+ const source = (streaming ? completePart(text) : text)
103
+ // Code in a fence is quoted material, not narration. An open fence runs to the end.
104
+ .replace(/```[\s\S]*?(?:```|$)/g, "\n")
105
+ .replace(/\*\*|`/g, "");
106
+ const steps: ThinkingStep[] = [];
107
+ for (const raw of source.split(/\n[ \t]*\n/)) {
108
+ const paragraph = raw.trim();
109
+ if (!paragraph) continue;
110
+ const step = milestoneStep(paragraph) ?? intentStep(paragraph);
111
+ if (step) steps.push(step);
112
+ }
113
+ return steps;
114
+ }
115
+
116
+ /** Card text of one step. */
117
+ export function formatThinkingStep(step: ThinkingStep): string {
118
+ return step.kind === "rethink" ? clean(`Rethinking: ${step.label}`) : step.label;
119
+ }
120
+
121
+ export function formatStepCount(count: number): string {
122
+ return count === 1 ? "1 step" : `${count} steps`;
123
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tian.zuo/pi-beautify-style",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Claude Code-style TUI for the pi coding agent: tool cards, rich diffs, compact mode, footer chips, and integrations for this repo's ask-user, background-terminals, and usage extensions.",
5
5
  "type": "module",
6
6
  "keywords": [