pi-minimal 0.1.0 → 0.2.2

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,26 @@
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"
4
+ import * as chrome from "./chrome.js"
14
5
  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
- }
6
+ import {
24
7
 
25
- type ToolResult = {
26
- content?: Array<{ type?: string; text?: string }>
27
- }
8
+ bashBody,
9
+ formatDuration,
10
+ formatOutcome,
11
+ layoutRow,
12
+ resultText,
13
+ rowGlyph,
14
+ toolItem,
15
+ type Outcome,
16
+ type RowStatus,
17
+ type ToolArgs,
18
+ type ToolKind,
19
+ type ToolResultLike,
20
+ } from "./tool-rows.js"
21
+
22
+ /** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
23
+ const margin = (): number => (typeof chrome.chromeMargin === "function" ? chrome.chromeMargin() : (chrome.CHROME_MARGIN ?? 1))
28
24
 
29
25
  type RenderOptions = {
30
26
  expanded?: boolean
@@ -33,6 +29,8 @@ type RenderOptions = {
33
29
  type RowState = {
34
30
  done?: boolean
35
31
  running?: boolean
32
+ startedAt?: number
33
+ outcome?: Outcome
36
34
  }
37
35
 
38
36
  type RenderContext = {
@@ -43,10 +41,17 @@ type RenderContext = {
43
41
  args?: ToolArgs
44
42
  toolCallId?: string
45
43
  expanded?: boolean
44
+ durationMs?: number
45
+ outputPad?: number
46
46
  }
47
47
 
48
48
  const live = createLiveWatch(400)
49
49
 
50
+ /** Left indent of every tool row: chrome margin plus one cell so rows sit inside message text. */
51
+ const rowIndent = () => margin() + 1
52
+ /** Max lines of output shown when a row is expanded. */
53
+ const EXPANDED_MAX_LINES = 400
54
+
50
55
  export function stopLiveWatch(): void {
51
56
  live.stop()
52
57
  }
@@ -55,107 +60,157 @@ function liveId(context: RenderContext, item: string): string {
55
60
  return context.toolCallId || item
56
61
  }
57
62
 
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")
63
+ function rowStatus(context: RenderContext): RowStatus {
64
+ if (context.isError) return "error"
65
+ if (context.state.outcome?.errorLine) return "error"
66
+ return rowIsRunning(context) ? "running" : "ok"
67
+ }
68
+
69
+ function statusColor(status: RowStatus): "error" | "warning" | "success" {
70
+ if (status === "error") return "error"
71
+ if (status === "running") return "warning"
72
+ return "success"
64
73
  }
65
74
 
66
- function collapsedResult() {
67
- return new Container()
75
+ function expandedLines(kind: ToolKind, result: ToolResultLike | undefined): string[] {
76
+ let text = resultText(result)
77
+ if (kind === "bash") text = bashBody(text).body
78
+ if (!text) return []
79
+ const lines = text.split("\n")
80
+ if (lines.length > EXPANDED_MAX_LINES) {
81
+ const hidden = lines.length - EXPANDED_MAX_LINES
82
+ return [...lines.slice(0, EXPANDED_MAX_LINES), `… ${hidden} more lines`]
83
+ }
84
+ return lines
85
+ }
86
+
87
+ function outputBlock(kind: ToolKind, result: ToolResultLike | undefined, theme: Theme) {
88
+ const lines = expandedLines(kind, result)
89
+ if (lines.length === 0) return new Container()
90
+ const indent = " ".repeat(rowIndent() + 2)
91
+ const rule = theme.fg("borderMuted", "│")
92
+ return {
93
+ render(width: number): string[] {
94
+ const inner = Math.max(0, width - indent.length - 2)
95
+ return lines.map((line) => `${indent}${rule} ${theme.fg("dim", truncateToWidth(line, inner))}`)
96
+ },
97
+ invalidate() {},
98
+ }
68
99
  }
69
100
 
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()
101
+ function errorLineBlock(line: string | undefined, theme: Theme) {
102
+ if (!line) return undefined
103
+ const indent = " ".repeat(rowIndent() + 2)
104
+ const rule = theme.fg("error", "│")
105
+ return {
106
+ render(width: number): string[] {
107
+ const inner = Math.max(0, width - indent.length - 2)
108
+ return [`${indent}${rule} ${theme.fg("error", truncateToWidth(line, inner))}`]
109
+ },
110
+ invalidate() {},
111
+ }
74
112
  }
75
113
 
76
- function activityBlock(
77
- kind: ToolKind,
78
- args: ToolArgs | undefined,
79
- theme: Theme,
80
- context: RenderContext,
81
- ) {
114
+ function activityRow(kind: ToolKind, args: ToolArgs | undefined, theme: Theme, context: RenderContext) {
82
115
  const item = toolItem(kind, args)
83
116
  const id = liveId(context, item)
84
- const row = nextGroupedRow(kind, item, id)
85
117
  const running = rowIsRunning(context)
86
118
  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"
119
+ if (running) {
120
+ context.state.startedAt ??= Date.now()
121
+ live.watch(id, context.invalidate)
122
+ } else {
123
+ live.unwatch(id)
124
+ }
90
125
  return {
91
126
  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)))
127
+ const status = rowStatus(context)
128
+ const blink = status === "running" && live.has(id)
129
+ const glyph = rowGlyph(status, blink ? live.frame : 0)
130
+ const outcome = context.state.outcome
131
+ const diff = status !== "running" ? outcome?.diff : undefined
132
+ let right = ""
133
+ if (status === "running") {
134
+ const started = context.state.startedAt
135
+ right = started ? formatDuration(Date.now() - started) : ""
136
+ } else if (outcome) {
137
+ right = outcome.text
138
+ if (diff) {
139
+ const stats = `+${diff.added} −${diff.removed}`
140
+ right = right ? `${stats} · ${right}` : stats
141
+ }
99
142
  }
100
- lines.push(" ".repeat(pad) + " " + theme.fg(color, truncateToWidth(row.item, Math.max(0, inner - 2))))
101
- return lines
143
+ const inner = Math.max(8, width - rowIndent())
144
+ const row = layoutRow(inner, kind, item, right)
145
+
146
+ let coloredRight: string
147
+ if (status === "running") coloredRight = theme.fg("warning", row.outcome)
148
+ else if (status === "error") coloredRight = theme.fg("error", row.outcome)
149
+ else if (diff && row.outcome) {
150
+ const rest = row.outcome.slice(`+${diff.added} −${diff.removed}`.length)
151
+ coloredRight =
152
+ theme.fg("success", `+${diff.added}`) + " " + theme.fg("error", `−${diff.removed}`) + theme.fg("dim", rest)
153
+ } else coloredRight = theme.fg("dim", row.outcome)
154
+
155
+ const line =
156
+ " ".repeat(rowIndent()) +
157
+ theme.fg(statusColor(status), glyph) +
158
+ " " +
159
+ theme.fg("dim", row.verb) +
160
+ " " +
161
+ theme.fg(status === "error" ? "text" : "muted", row.item) +
162
+ " ".repeat(row.gap) +
163
+ coloredRight
164
+ return [line]
102
165
  },
103
166
  invalidate() {},
104
167
  }
105
168
  }
106
169
 
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
- }
170
+ const TOOL_KINDS: ReadonlySet<string> = new Set<ToolKind>(["bash", "read", "edit", "write", "grep", "find", "ls"])
114
171
 
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
- }
172
+ export function isToolKind(name: string): name is ToolKind {
173
+ return TOOL_KINDS.has(name)
137
174
  }
138
175
 
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.
176
+ export function compactRenderers(kind: ToolKind): ToolRenderers {
177
+ return {
178
+ renderShell: "self",
179
+ renderCall(args, theme, context) {
180
+ return activityRow(kind, args as ToolArgs, theme, context as RenderContext)
181
+ },
182
+ renderResult(result, options, theme, context) {
183
+ const renderContext = context as RenderContext
184
+ const typedResult = result as ToolResultLike
185
+ if (!renderContext.isPartial) {
186
+ renderContext.state.done = true
187
+ renderContext.state.running = false
188
+ renderContext.state.outcome = formatOutcome(
189
+ kind,
190
+ renderContext.args,
191
+ typedResult,
192
+ Boolean(renderContext.isError),
193
+ renderContext.durationMs,
194
+ )
195
+ live.unwatch(liveId(renderContext, toolItem(kind, renderContext.args)))
196
+ }
197
+ const container = new Container()
198
+ const errorLine = errorLineBlock(renderContext.state.outcome?.errorLine, theme)
199
+ if (errorLine && !(options as RenderOptions).expanded) container.addChild(errorLine)
200
+ if ((options as RenderOptions).expanded) container.addChild(outputBlock(kind, typedResult, theme))
201
+ return container
202
+ },
152
203
  }
204
+ }
153
205
 
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)
206
+ /**
207
+ * Attach the compact renderers to the built-in tools without re-registering them. Registered at
208
+ * extension load so a resumed transcript draws its earlier tool calls the same way as new ones.
209
+ */
210
+ export function installToolRenderers(pi: ExtensionAPI): void {
211
+ if (typeof pi.registerToolRenderer !== "function") return
212
+ pi.registerToolRenderer((toolName, next) => {
213
+ if (!isToolKind(toolName)) return next()
214
+ return compactRenderers(toolName)
215
+ })
161
216
  }
@@ -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
+ }