pi-minimal 0.1.0 → 0.2.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/src/tool-rows.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"
2
+
1
3
  export type ToolKind = "read" | "edit" | "write" | "bash" | "grep" | "find" | "ls"
2
4
 
3
5
  export type ToolArgs = {
@@ -6,24 +8,22 @@ export type ToolArgs = {
6
8
  file_path?: unknown
7
9
  pattern?: unknown
8
10
  target_directory?: unknown
11
+ content?: unknown
9
12
  }
10
13
 
11
- export type GroupedRow = {
12
- header?: string
13
- item: string
14
+ export type ToolResultLike = {
15
+ content?: Array<{ type?: string; text?: string }>
16
+ details?: unknown
14
17
  }
15
18
 
16
- type ActivityGroup = {
17
- verb: string
18
- leadId: string
19
- seen: Set<string>
20
- }
19
+ export type RowStatus = "running" | "ok" | "error"
21
20
 
22
- let activityGroup: ActivityGroup | undefined
21
+ export type RowGlyph = "◐" | "◓" | "✓" | "✗"
23
22
 
24
- export function resetActivityGroup(): void {
25
- activityGroup = undefined
26
- }
23
+ /** Width of the verb column so items align across rows. */
24
+ export const VERB_WIDTH = 5
25
+
26
+ const BASH_STATUS = /^Command (?:exited with code (\d+)|terminated without an exit code)$/
27
27
 
28
28
  export function shortName(path: string): string {
29
29
  const parts = path.split("/").filter(Boolean)
@@ -47,12 +47,163 @@ export function toolItem(kind: ToolKind, args: ToolArgs | undefined): string {
47
47
  return path ? shortName(path) : kind
48
48
  }
49
49
 
50
- export function nextGroupedRow(verb: string, item: string, id: string): GroupedRow {
51
- if (activityGroup && activityGroup.verb === verb) {
52
- activityGroup.seen.add(id)
53
- if (id === activityGroup.leadId) return { header: verb, item }
54
- return { header: undefined, item }
50
+ export function resultText(result: ToolResultLike | undefined): string {
51
+ if (!result?.content) return ""
52
+ return result.content
53
+ .filter((part) => part.type === "text" && typeof part.text === "string")
54
+ .map((part) => part.text as string)
55
+ .join("\n")
56
+ }
57
+
58
+ export function rowGlyph(status: RowStatus, frame: 0 | 1): RowGlyph {
59
+ if (status === "running") return frame === 1 ? "◓" : "◐"
60
+ return status === "error" ? "✗" : "✓"
61
+ }
62
+
63
+ export function formatDuration(ms: number | undefined): string {
64
+ if (ms === undefined || !Number.isFinite(ms)) return ""
65
+ if (ms < 1000) return `${Math.round(ms / 100) / 10}s`
66
+ if (ms < 60_000) return `${Math.round(ms / 100) / 10}s`
67
+ const minutes = Math.floor(ms / 60_000)
68
+ const seconds = Math.round((ms % 60_000) / 1000)
69
+ return `${minutes}m ${seconds}s`
70
+ }
71
+
72
+ function plural(n: number, word: string, pluralWord = `${word}s`): string {
73
+ return `${n} ${n === 1 ? word : pluralWord}`
74
+ }
75
+
76
+ function countLines(text: string): number {
77
+ if (!text) return 0
78
+ return text.split("\n").filter((line) => line.length > 0).length
79
+ }
80
+
81
+ /** Strip pi's bash status trailer and truncation note from output text. */
82
+ export function bashBody(text: string): { body: string; exitCode: number | undefined } {
83
+ const lines = text.split("\n")
84
+ let exitCode: number | undefined
85
+ while (lines.length > 0) {
86
+ const last = lines[lines.length - 1].trim()
87
+ const match = BASH_STATUS.exec(last)
88
+ if (match) {
89
+ exitCode = match[1] !== undefined ? Number(match[1]) : undefined
90
+ lines.pop()
91
+ continue
92
+ }
93
+ if (last.startsWith("[Showing ") && last.endsWith("]")) {
94
+ lines.pop()
95
+ continue
96
+ }
97
+ if (last === "") {
98
+ lines.pop()
99
+ continue
100
+ }
101
+ break
102
+ }
103
+ return { body: lines.join("\n"), exitCode }
104
+ }
105
+
106
+ export function diffStats(diff: string): { added: number; removed: number } {
107
+ let added = 0
108
+ let removed = 0
109
+ for (const line of diff.split("\n")) {
110
+ if (line.startsWith("+++") || line.startsWith("---")) continue
111
+ if (line.startsWith("+")) added += 1
112
+ else if (line.startsWith("-")) removed += 1
113
+ }
114
+ return { added, removed }
115
+ }
116
+
117
+ export type Outcome = {
118
+ /** Short right-aligned summary, e.g. "34 lines · 1.0s". */
119
+ text: string
120
+ /** Optional +N / −N pair rendered in success/error colours. */
121
+ diff?: { added: number; removed: number }
122
+ /** First meaningful line of an error, shown under the row. */
123
+ errorLine?: string
124
+ }
125
+
126
+ export function formatOutcome(
127
+ kind: ToolKind,
128
+ args: ToolArgs | undefined,
129
+ result: ToolResultLike | undefined,
130
+ isError: boolean,
131
+ durationMs: number | undefined,
132
+ ): Outcome {
133
+ const text = resultText(result)
134
+ const dur = formatDuration(durationMs)
135
+ const join = (...parts: string[]) => parts.filter(Boolean).join(" · ")
136
+
137
+ if (kind === "bash") {
138
+ const { body, exitCode } = bashBody(text)
139
+ if (isError || (exitCode !== undefined && exitCode !== 0)) {
140
+ const code = exitCode !== undefined ? `exit ${exitCode}` : "failed"
141
+ return { text: join(code, dur), errorLine: firstLine(body) }
142
+ }
143
+ const lines = countLines(body)
144
+ const summary = lines === 0 || body === "(no output)" ? "ok" : plural(lines, "line")
145
+ return { text: join(summary, dur) }
146
+ }
147
+
148
+ if (isError) return { text: join("failed", dur), errorLine: firstLine(text) }
149
+
150
+ if (kind === "edit") {
151
+ const details = result?.details as { diff?: unknown } | undefined
152
+ const diff = typeof details?.diff === "string" ? diffStats(details.diff) : undefined
153
+ return diff ? { text: dur, diff } : { text: join("edited", dur) }
154
+ }
155
+ if (kind === "write") {
156
+ const bytes = typeof args?.content === "string" ? Buffer.byteLength(args.content, "utf8") : undefined
157
+ return { text: join(bytes === undefined ? "written" : formatBytes(bytes), dur) }
158
+ }
159
+ if (kind === "read") {
160
+ return { text: join(plural(countLines(text), "line"), dur) }
161
+ }
162
+ if (kind === "grep") {
163
+ if (/^No matches found/m.test(text)) return { text: join("0 matches", dur) }
164
+ return { text: join(plural(countLines(text), "match"), dur) }
165
+ }
166
+ // find / ls
167
+ return { text: join(plural(countLines(text), "entry", "entries"), dur) }
168
+ }
169
+
170
+ export function firstLine(text: string): string | undefined {
171
+ for (const line of text.split("\n")) {
172
+ const trimmed = line.trim()
173
+ if (trimmed && trimmed !== "(no output)") return trimmed
174
+ }
175
+ return undefined
176
+ }
177
+
178
+ export function formatBytes(n: number): string {
179
+ if (n < 1024) return `${n} B`
180
+ if (n < 1024 * 1024) return `${Math.round(n / 102.4) / 10} KB`
181
+ return `${Math.round(n / (1024 * 102.4)) / 10} MB`
182
+ }
183
+
184
+ /**
185
+ * Lay out one row: `<glyph> <verb> <item> ... <outcome>`. The item is truncated so the outcome
186
+ * stays visible; the outcome is dropped only when the row is too narrow to show both.
187
+ */
188
+ export function layoutRow(
189
+ width: number,
190
+ verb: string,
191
+ item: string,
192
+ outcome: string,
193
+ ): { verb: string; item: string; gap: number; outcome: string } {
194
+ const verbCol = verb.padEnd(VERB_WIDTH)
195
+ // glyph + space + verb column + space
196
+ const prefixWidth = 2 + VERB_WIDTH + 1
197
+ const outcomeWidth = visibleWidth(outcome)
198
+ const minGap = 2
199
+ let shownOutcome = outcome
200
+ let itemRoom = width - prefixWidth - (outcomeWidth ? outcomeWidth + minGap : 0)
201
+ if (itemRoom < 12 && outcomeWidth) {
202
+ shownOutcome = ""
203
+ itemRoom = width - prefixWidth
55
204
  }
56
- activityGroup = { verb, leadId: id, seen: new Set([id]) }
57
- return { header: verb, item }
205
+ const shownItem = truncateToWidth(item, Math.max(0, itemRoom), "…")
206
+ const used = prefixWidth + visibleWidth(shownItem) + visibleWidth(shownOutcome)
207
+ const gap = shownOutcome ? Math.max(minGap, width - used) : 0
208
+ return { verb: verbCol, item: shownItem, gap, outcome: shownOutcome }
58
209
  }
package/src/tools.ts CHANGED
@@ -1,30 +1,21 @@
1
- import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent"
2
- import {
3
- createBashToolDefinition,
4
- createEditToolDefinition,
5
- createFindToolDefinition,
6
- createGrepToolDefinition,
7
- createLsToolDefinition,
8
- createReadToolDefinition,
9
- createWriteToolDefinition,
10
- SettingsManager,
11
- } from "@earendil-works/pi-coding-agent"
12
- import { Container, Text, truncateToWidth } from "@earendil-works/pi-tui"
13
- import { activityBullet, rowIsRunning } from "./activity.js"
1
+ import type { ExtensionAPI, Theme, ToolRenderers } from "@earendil-works/pi-coding-agent"
2
+ import { Container, truncateToWidth } from "@earendil-works/pi-tui"
3
+ import { rowIsRunning } from "./activity.js"
14
4
  import { createLiveWatch } from "./live-watch.js"
15
- import { nextGroupedRow, toolItem, type ToolKind } from "./tool-rows.js"
16
-
17
- type ToolArgs = {
18
- command?: unknown
19
- path?: unknown
20
- file_path?: unknown
21
- pattern?: unknown
22
- target_directory?: unknown
23
- }
24
-
25
- type ToolResult = {
26
- content?: Array<{ type?: string; text?: string }>
27
- }
5
+ import {
6
+ bashBody,
7
+ formatDuration,
8
+ formatOutcome,
9
+ layoutRow,
10
+ resultText,
11
+ rowGlyph,
12
+ toolItem,
13
+ type Outcome,
14
+ type RowStatus,
15
+ type ToolArgs,
16
+ type ToolKind,
17
+ type ToolResultLike,
18
+ } from "./tool-rows.js"
28
19
 
29
20
  type RenderOptions = {
30
21
  expanded?: boolean
@@ -33,6 +24,8 @@ type RenderOptions = {
33
24
  type RowState = {
34
25
  done?: boolean
35
26
  running?: boolean
27
+ startedAt?: number
28
+ outcome?: Outcome
36
29
  }
37
30
 
38
31
  type RenderContext = {
@@ -43,10 +36,17 @@ type RenderContext = {
43
36
  args?: ToolArgs
44
37
  toolCallId?: string
45
38
  expanded?: boolean
39
+ durationMs?: number
40
+ outputPad?: number
46
41
  }
47
42
 
48
43
  const live = createLiveWatch(400)
49
44
 
45
+ /** Left indent of every tool row, in cells. */
46
+ const ROW_INDENT = 2
47
+ /** Max lines of output shown when a row is expanded. */
48
+ const EXPANDED_MAX_LINES = 400
49
+
50
50
  export function stopLiveWatch(): void {
51
51
  live.stop()
52
52
  }
@@ -55,107 +55,157 @@ function liveId(context: RenderContext, item: string): string {
55
55
  return context.toolCallId || item
56
56
  }
57
57
 
58
- function contentText(result: ToolResult | undefined): string {
59
- if (!result?.content) return ""
60
- return result.content
61
- .filter((part) => part.type === "text" && typeof part.text === "string")
62
- .map((part) => part.text as string)
63
- .join("\n")
58
+ function rowStatus(context: RenderContext): RowStatus {
59
+ if (context.isError) return "error"
60
+ if (context.state.outcome?.errorLine) return "error"
61
+ return rowIsRunning(context) ? "running" : "ok"
62
+ }
63
+
64
+ function statusColor(status: RowStatus): "error" | "warning" | "success" {
65
+ if (status === "error") return "error"
66
+ if (status === "running") return "warning"
67
+ return "success"
64
68
  }
65
69
 
66
- function collapsedResult() {
67
- return new Container()
70
+ function expandedLines(kind: ToolKind, result: ToolResultLike | undefined): string[] {
71
+ let text = resultText(result)
72
+ if (kind === "bash") text = bashBody(text).body
73
+ if (!text) return []
74
+ const lines = text.split("\n")
75
+ if (lines.length > EXPANDED_MAX_LINES) {
76
+ const hidden = lines.length - EXPANDED_MAX_LINES
77
+ return [...lines.slice(0, EXPANDED_MAX_LINES), `… ${hidden} more lines`]
78
+ }
79
+ return lines
68
80
  }
69
81
 
70
- function expandedContent(result: ToolResult | undefined, options: RenderOptions) {
71
- if (!options.expanded) return collapsedResult()
72
- const text = contentText(result)
73
- return text ? new Text(text, 0, 0) : collapsedResult()
82
+ function outputBlock(kind: ToolKind, result: ToolResultLike | undefined, theme: Theme) {
83
+ const lines = expandedLines(kind, result)
84
+ if (lines.length === 0) return new Container()
85
+ const indent = " ".repeat(ROW_INDENT + 2)
86
+ const rule = theme.fg("borderMuted", "│")
87
+ return {
88
+ render(width: number): string[] {
89
+ const inner = Math.max(0, width - indent.length - 2)
90
+ return lines.map((line) => `${indent}${rule} ${theme.fg("dim", truncateToWidth(line, inner))}`)
91
+ },
92
+ invalidate() {},
93
+ }
74
94
  }
75
95
 
76
- function activityBlock(
77
- kind: ToolKind,
78
- args: ToolArgs | undefined,
79
- theme: Theme,
80
- context: RenderContext,
81
- ) {
96
+ function errorLineBlock(line: string | undefined, theme: Theme) {
97
+ if (!line) return undefined
98
+ const indent = " ".repeat(ROW_INDENT + 2)
99
+ const rule = theme.fg("error", "│")
100
+ return {
101
+ render(width: number): string[] {
102
+ const inner = Math.max(0, width - indent.length - 2)
103
+ return [`${indent}${rule} ${theme.fg("error", truncateToWidth(line, inner))}`]
104
+ },
105
+ invalidate() {},
106
+ }
107
+ }
108
+
109
+ function activityRow(kind: ToolKind, args: ToolArgs | undefined, theme: Theme, context: RenderContext) {
82
110
  const item = toolItem(kind, args)
83
111
  const id = liveId(context, item)
84
- const row = nextGroupedRow(kind, item, id)
85
112
  const running = rowIsRunning(context)
86
113
  context.state.running = running
87
- if (running) live.watch(id, context.invalidate)
88
- else live.unwatch(id)
89
- const color = context.isError ? "error" : running ? "warning" : "dim"
114
+ if (running) {
115
+ context.state.startedAt ??= Date.now()
116
+ live.watch(id, context.invalidate)
117
+ } else {
118
+ live.unwatch(id)
119
+ }
90
120
  return {
91
121
  render(width: number): string[] {
92
- const pad = 1
93
- const inner = Math.max(0, width - pad)
94
- const lines: string[] = []
95
- if (row.header) {
96
- const blink = Boolean(context.state.running) && live.has(id)
97
- const bullet = activityBullet(blink, live.frame)
98
- lines.push(" ".repeat(pad) + theme.fg(color, truncateToWidth(`${bullet} ${row.header}`, inner)))
122
+ const status = rowStatus(context)
123
+ const blink = status === "running" && live.has(id)
124
+ const glyph = rowGlyph(status, blink ? live.frame : 0)
125
+ const outcome = context.state.outcome
126
+ const diff = status !== "running" ? outcome?.diff : undefined
127
+ let right = ""
128
+ if (status === "running") {
129
+ const started = context.state.startedAt
130
+ right = started ? formatDuration(Date.now() - started) : ""
131
+ } else if (outcome) {
132
+ right = outcome.text
133
+ if (diff) {
134
+ const stats = `+${diff.added} −${diff.removed}`
135
+ right = right ? `${stats} · ${right}` : stats
136
+ }
99
137
  }
100
- lines.push(" ".repeat(pad) + " " + theme.fg(color, truncateToWidth(row.item, Math.max(0, inner - 2))))
101
- return lines
138
+ const inner = Math.max(8, width - ROW_INDENT)
139
+ const row = layoutRow(inner, kind, item, right)
140
+
141
+ let coloredRight: string
142
+ if (status === "running") coloredRight = theme.fg("warning", row.outcome)
143
+ else if (status === "error") coloredRight = theme.fg("error", row.outcome)
144
+ else if (diff && row.outcome) {
145
+ const rest = row.outcome.slice(`+${diff.added} −${diff.removed}`.length)
146
+ coloredRight =
147
+ theme.fg("success", `+${diff.added}`) + " " + theme.fg("error", `−${diff.removed}`) + theme.fg("dim", rest)
148
+ } else coloredRight = theme.fg("dim", row.outcome)
149
+
150
+ const line =
151
+ " ".repeat(ROW_INDENT) +
152
+ theme.fg(statusColor(status), glyph) +
153
+ " " +
154
+ theme.fg("dim", row.verb) +
155
+ " " +
156
+ theme.fg(status === "error" ? "text" : "muted", row.item) +
157
+ " ".repeat(row.gap) +
158
+ coloredRight
159
+ return [line]
102
160
  },
103
161
  invalidate() {},
104
162
  }
105
163
  }
106
164
 
107
- type WrappableTool = {
108
- name: string
109
- label: string
110
- description: string
111
- parameters: Parameters<ExtensionAPI["registerTool"]>[0]["parameters"]
112
- execute: Parameters<ExtensionAPI["registerTool"]>[0]["execute"]
113
- }
165
+ const TOOL_KINDS: ReadonlySet<string> = new Set<ToolKind>(["bash", "read", "edit", "write", "grep", "find", "ls"])
114
166
 
115
- function wrapOne(pi: ExtensionAPI, kind: ToolKind, factory: () => WrappableTool) {
116
- try {
117
- const tool = factory()
118
- pi.registerTool({
119
- ...tool,
120
- renderShell: "self",
121
- renderCall(args, theme, context) {
122
- return activityBlock(kind, args as ToolArgs, theme, context as RenderContext)
123
- },
124
- renderResult(result, options, _theme, context) {
125
- const renderContext = context as RenderContext
126
- if (!renderContext.isPartial) {
127
- renderContext.state.done = true
128
- renderContext.state.running = false
129
- live.unwatch(liveId(renderContext, toolItem(kind, renderContext.args)))
130
- }
131
- return expandedContent(result as ToolResult, options)
132
- },
133
- })
134
- } catch {
135
- // A missing built-in tool leaves the rest of the compact renderers available.
136
- }
167
+ export function isToolKind(name: string): name is ToolKind {
168
+ return TOOL_KINDS.has(name)
137
169
  }
138
170
 
139
- export function wrapBuiltinTools(pi: ExtensionAPI, ctx: ExtensionContext): void {
140
- let commandPrefix: string | undefined
141
- let shellPath: string | undefined
142
- let autoResizeImages: boolean | undefined
143
- try {
144
- const settings = SettingsManager.create(ctx.cwd, undefined, {
145
- projectTrusted: ctx.isProjectTrusted(),
146
- })
147
- commandPrefix = settings.getShellCommandPrefix()
148
- shellPath = settings.getShellPath()
149
- autoResizeImages = settings.getImageAutoResize()
150
- } catch {
151
- // Tool wrapping still works when optional shell or image settings are unavailable.
171
+ export function compactRenderers(kind: ToolKind): ToolRenderers {
172
+ return {
173
+ renderShell: "self",
174
+ renderCall(args, theme, context) {
175
+ return activityRow(kind, args as ToolArgs, theme, context as RenderContext)
176
+ },
177
+ renderResult(result, options, theme, context) {
178
+ const renderContext = context as RenderContext
179
+ const typedResult = result as ToolResultLike
180
+ if (!renderContext.isPartial) {
181
+ renderContext.state.done = true
182
+ renderContext.state.running = false
183
+ renderContext.state.outcome = formatOutcome(
184
+ kind,
185
+ renderContext.args,
186
+ typedResult,
187
+ Boolean(renderContext.isError),
188
+ renderContext.durationMs,
189
+ )
190
+ live.unwatch(liveId(renderContext, toolItem(kind, renderContext.args)))
191
+ }
192
+ const container = new Container()
193
+ const errorLine = errorLineBlock(renderContext.state.outcome?.errorLine, theme)
194
+ if (errorLine && !(options as RenderOptions).expanded) container.addChild(errorLine)
195
+ if ((options as RenderOptions).expanded) container.addChild(outputBlock(kind, typedResult, theme))
196
+ return container
197
+ },
152
198
  }
199
+ }
153
200
 
154
- wrapOne(pi, "bash", () => createBashToolDefinition(ctx.cwd, { commandPrefix, shellPath }) as WrappableTool)
155
- wrapOne(pi, "read", () => createReadToolDefinition(ctx.cwd, { autoResizeImages }) as WrappableTool)
156
- wrapOne(pi, "edit", () => createEditToolDefinition(ctx.cwd) as WrappableTool)
157
- wrapOne(pi, "write", () => createWriteToolDefinition(ctx.cwd) as WrappableTool)
158
- wrapOne(pi, "grep", () => createGrepToolDefinition(ctx.cwd) as WrappableTool)
159
- wrapOne(pi, "find", () => createFindToolDefinition(ctx.cwd) as WrappableTool)
160
- wrapOne(pi, "ls", () => createLsToolDefinition(ctx.cwd) as WrappableTool)
201
+ /**
202
+ * Attach the compact renderers to the built-in tools without re-registering them. Registered at
203
+ * extension load so a resumed transcript draws its earlier tool calls the same way as new ones.
204
+ */
205
+ export function installToolRenderers(pi: ExtensionAPI): void {
206
+ if (typeof pi.registerToolRenderer !== "function") return
207
+ pi.registerToolRenderer((toolName, next) => {
208
+ if (!isToolKind(toolName)) return next()
209
+ return compactRenderers(toolName)
210
+ })
161
211
  }
@@ -0,0 +1,51 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
2
+
3
+ export const THINKING_GUTTER = "┊ "
4
+
5
+ const FENCE = /^\s*(```|~~~)/
6
+ const BLOCK_START = /^\s*(#{1,6}\s|[-*+]\s|\d+[.)]\s|>|\||```|~~~|---\s*$|\*\*\*\s*$|___\s*$)/
7
+
8
+ /**
9
+ * Prefix the first line of every plain paragraph in thinking Markdown with a gutter glyph.
10
+ * Lines that start a Markdown block (heading, list, quote, table, fence, rule) are left alone so
11
+ * they keep parsing, and nothing inside a fenced code block is touched.
12
+ */
13
+ export function gutterThinking(markdown: string): string {
14
+ if (!markdown) return markdown
15
+ const lines = markdown.split("\n")
16
+ const out: string[] = []
17
+ let inFence = false
18
+ let atParagraphStart = true
19
+ for (const line of lines) {
20
+ if (FENCE.test(line)) {
21
+ inFence = !inFence
22
+ out.push(line)
23
+ atParagraphStart = !inFence
24
+ continue
25
+ }
26
+ if (inFence) {
27
+ out.push(line)
28
+ continue
29
+ }
30
+ if (line.trim() === "") {
31
+ out.push(line)
32
+ atParagraphStart = true
33
+ continue
34
+ }
35
+ if (atParagraphStart && !BLOCK_START.test(line) && !line.startsWith(THINKING_GUTTER)) {
36
+ out.push(`${THINKING_GUTTER}${line}`)
37
+ } else {
38
+ out.push(line)
39
+ }
40
+ atParagraphStart = false
41
+ }
42
+ return out.join("\n")
43
+ }
44
+
45
+ export function installTranscript(pi: ExtensionAPI): void {
46
+ if (typeof pi.registerMarkdownTransformer !== "function") return
47
+ pi.registerMarkdownTransformer((markdown, context) => {
48
+ if (context.messageType !== "assistant-thinking") return markdown
49
+ return gutterThinking(markdown)
50
+ })
51
+ }