pi-ui-extend 0.1.26 → 0.1.28

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.
@@ -25,6 +25,9 @@ declare const NERD_FONT_ICONS: {
25
25
  readonly thinkingExpanded: "󰌵";
26
26
  readonly stopCircle: "󰙥";
27
27
  readonly timerSand: "󰔟";
28
+ readonly toolBodyEnd: "└";
29
+ readonly toolBodyGutter: "│";
30
+ readonly toolPreviewTruncated: "⊞";
28
31
  readonly down: "󰁅";
29
32
  };
30
33
  export type AppIconName = keyof typeof NERD_FONT_ICONS;
package/dist/app/icons.js CHANGED
@@ -31,6 +31,9 @@ const NERD_FONT_ICONS = {
31
31
  thinkingExpanded: "\u{f0335}",
32
32
  stopCircle: "\u{f0665}",
33
33
  timerSand: "\u{f051f}",
34
+ toolBodyEnd: "└",
35
+ toolBodyGutter: "│",
36
+ toolPreviewTruncated: "⊞",
34
37
  down: "\u{f0045}",
35
38
  };
36
39
  const FALLBACK_ICONS = {
@@ -58,6 +61,9 @@ const FALLBACK_ICONS = {
58
61
  thinkingExpanded: ">",
59
62
  stopCircle: "■",
60
63
  timerSand: "⏳",
64
+ toolBodyEnd: "`",
65
+ toolBodyGutter: "|",
66
+ toolPreviewTruncated: "+",
61
67
  down: "v",
62
68
  };
63
69
  export const APP_ICON_THEMES = {
@@ -42,7 +42,7 @@ export function renderConversationToolEntry(entry, width, options) {
42
42
  collapsedBody: display.collapsedBody,
43
43
  expandedText: display.expandedText,
44
44
  syntaxHighlight: display.syntaxHighlight,
45
- }, rule, width, options.colors, { superCompact: Boolean(options.superCompactTools) });
45
+ }, rule, width, options.colors, { superCompact: Boolean(options.superCompactTools), showGutter: true });
46
46
  return attachImageClickTargets(lines, entry.id, entry.images, { foreground: options.colors.info, underline: true });
47
47
  }
48
48
  export function renderThinkingEntry(entry, width, options) {
@@ -63,7 +63,7 @@ export function renderThinkingEntry(entry, width, options) {
63
63
  expandedText: compactExpandedText || "(empty)",
64
64
  bodyWrap: "word",
65
65
  syntaxHighlight: compactExpandedText ? markdownSyntaxHighlightsForText(compactExpandedText) : undefined,
66
- }, rule, width, options.colors, { superCompact: Boolean(options.superCompactTools && !forceExpanded), backgroundOverride: options.colors.thinkingMessageBackground, skipHeaderBackground: true });
66
+ }, rule, width, options.colors, { superCompact: Boolean(options.superCompactTools && !forceExpanded), backgroundOverride: options.colors.thinkingMessageBackground, skipHeaderBackground: true, showGutter: false });
67
67
  }
68
68
  function trimTrailingBlankLines(text) {
69
69
  return text.replace(/(?:\r?\n[ \t]*)+$/u, "");
@@ -88,7 +88,7 @@ function renderTodoToolEntry(entry, width, options) {
88
88
  output: body,
89
89
  collapsedBody: body,
90
90
  expandedText: body,
91
- }, rule, width, options.colors, { superCompact: Boolean(options.superCompactTools) });
91
+ }, rule, width, options.colors, { superCompact: Boolean(options.superCompactTools), showGutter: true });
92
92
  }
93
93
  function todoDetailsText(details) {
94
94
  const lines = [];
@@ -25,5 +25,6 @@ export type ToolBlockRenderOptions = {
25
25
  superCompact?: boolean;
26
26
  backgroundOverride?: string;
27
27
  skipHeaderBackground?: boolean;
28
+ showGutter?: boolean;
28
29
  };
29
30
  export declare function renderToolBlock(entry: ToolBlockEntry, rule: ResolvedToolRule, width: number, colors: Theme["colors"], options?: ToolBlockRenderOptions): RenderedLine[];
@@ -1,7 +1,17 @@
1
1
  import { resolveColor } from "../../config.js";
2
2
  import { expandTabs, sliceByDisplayWidth, stringDisplayWidth, wrapDisplayLineByWords } from "../../terminal-width.js";
3
3
  import { alertIconPrefixLength, hasToolLspDiagnosticsAfterMutation, lspDiagnosticSeverityForLine, sanitizeText, toolStatusIcon, toolStatusIconColor, wrapLine } from "./render-text.js";
4
- const TRUNCATED_PREVIEW_MARKER = "▶ ";
4
+ import { APP_ICONS } from "../icons.js";
5
+ const TOOL_BODY_PREFIX = " ";
6
+ function truncatedPreviewMarker() {
7
+ return `${APP_ICONS.toolPreviewTruncated} `;
8
+ }
9
+ function toolBodyGutterPrefix() {
10
+ return `${APP_ICONS.toolBodyGutter} `;
11
+ }
12
+ function toolBodyEndPrefix() {
13
+ return `${APP_ICONS.toolBodyEnd} `;
14
+ }
5
15
  export function renderToolBlock(entry, rule, width, colors, options = {}) {
6
16
  if (rule.hidden)
7
17
  return [];
@@ -19,6 +29,7 @@ export function renderToolBlock(entry, rule, width, colors, options = {}) {
19
29
  const headerArgsWidth = width - stringDisplayWidth(headerPrefix) - 1;
20
30
  const clippedHeaderArgs = headerArgsWidth > 0 ? sliceByDisplayWidth(headerArgs, headerArgsWidth) : "";
21
31
  const target = { kind: "tool", id: entry.id };
32
+ const showGutter = options.showGutter ?? true;
22
33
  const header = clippedHeaderArgs ? `${headerPrefix} ${clippedHeaderArgs}` : headerPrefix;
23
34
  const headerArgsStart = clippedHeaderArgs ? headerPrefix.length + 1 : header.length;
24
35
  const headerLine = {
@@ -34,7 +45,7 @@ export function renderToolBlock(entry, rule, width, colors, options = {}) {
34
45
  };
35
46
  const headerLines = [headerLine];
36
47
  if (expanded) {
37
- headerLines.push(...renderToolBodyLines(entry.expandedText, width, target, toolOutputColor, entry.bodyStyle, colors, entry.syntaxHighlight, entry.bodyWrap, hasLspDiagnostics, entry.bodyLineStyles, entry.preserveAnsi));
48
+ headerLines.push(...renderToolBodyLines(entry.expandedText, width, target, toolOutputColor, entry.bodyStyle, colors, entry.syntaxHighlight, entry.bodyWrap, hasLspDiagnostics, entry.bodyLineStyles, entry.preserveAnsi, showGutter));
38
49
  if (options.skipHeaderBackground && headerLines.length > 1) {
39
50
  applyBackground(headerLines.slice(1));
40
51
  }
@@ -49,7 +60,7 @@ export function renderToolBlock(entry, rule, width, colors, options = {}) {
49
60
  if (!body || rule.previewLines === 0)
50
61
  return headerLines;
51
62
  if (!options.superCompact) {
52
- headerLines.push(...renderCollapsedPreviewLines(entry, body, rule, width, target, toolOutputColor, colors, hasLspDiagnostics));
63
+ headerLines.push(...renderCollapsedPreviewLines(entry, body, rule, width, target, toolOutputColor, colors, hasLspDiagnostics, showGutter));
53
64
  applyBackground(headerLines);
54
65
  return headerLines;
55
66
  }
@@ -60,13 +71,14 @@ export function renderToolBlock(entry, rule, width, colors, options = {}) {
60
71
  const availablePreviewWidth = width - stringDisplayWidth(header) - stringDisplayWidth(separator);
61
72
  if (availablePreviewWidth <= 0)
62
73
  return headerLines;
63
- const previewText = preview.overflow ? `${TRUNCATED_PREVIEW_MARKER}${preview.text}` : preview.text;
74
+ const markerPrefix = truncatedPreviewMarker();
75
+ const previewText = preview.overflow ? `${markerPrefix}${preview.text}` : preview.text;
64
76
  const clippedPreview = sliceByDisplayWidth(previewText, availablePreviewWidth);
65
77
  if (!clippedPreview)
66
78
  return headerLines;
67
79
  headerLine.text = `${header}${separator}${clippedPreview}`;
68
80
  const previewStart = header.length + separator.length;
69
- const previewTextStart = previewStart + (preview.overflow ? TRUNCATED_PREVIEW_MARKER.length : 0);
81
+ const previewTextStart = previewStart + (preview.overflow ? markerPrefix.length : 0);
70
82
  headerLine.segments = [
71
83
  ...(headerLine.segments ?? []),
72
84
  ...(preview.overflow ? [{ start: previewStart, end: previewStart + 1, foreground: colors.statusDotBase }] : []),
@@ -74,8 +86,8 @@ export function renderToolBlock(entry, rule, width, colors, options = {}) {
74
86
  ];
75
87
  return headerLines;
76
88
  }
77
- function renderCollapsedPreviewLines(entry, body, rule, width, target, color, colors, hasLspDiagnostics) {
78
- const allLines = renderToolBodyLines(body, width, target, color, entry.bodyStyle, colors, undefined, entry.bodyWrap, hasLspDiagnostics, entry.bodyLineStyles, entry.preserveAnsi);
89
+ function renderCollapsedPreviewLines(entry, body, rule, width, target, color, colors, hasLspDiagnostics, showGutter) {
90
+ const allLines = renderToolBodyLines(body, width, target, color, entry.bodyStyle, colors, undefined, entry.bodyWrap, hasLspDiagnostics, entry.bodyLineStyles, entry.preserveAnsi, showGutter);
79
91
  if (rule.previewLines >= allLines.length)
80
92
  return allLines;
81
93
  const previewLines = rule.direction === "tail" ? allLines.slice(-rule.previewLines) : allLines.slice(0, rule.previewLines);
@@ -92,20 +104,33 @@ function markTruncatedPreviewLine(lines, direction, markerColor) {
92
104
  if (lines.length === 0)
93
105
  return lines;
94
106
  const markerIndex = direction === "tail" ? 0 : lines.length - 1;
107
+ const gutterPrefix = toolBodyGutterPrefix();
108
+ const markerPrefix = truncatedPreviewMarker();
95
109
  return lines.map((line, index) => {
96
110
  if (index !== markerIndex)
97
111
  return line;
112
+ const text = line.text.startsWith(gutterPrefix)
113
+ ? `${markerPrefix}${line.text.slice(gutterPrefix.length)}`
114
+ : line.text.startsWith(TOOL_BODY_PREFIX)
115
+ ? `${markerPrefix}${line.text.slice(TOOL_BODY_PREFIX.length)}`
116
+ : `${markerPrefix}${line.text}`;
117
+ const existingSegments = (line.segments ?? []).filter((segment) => !(segment.start === 0 && segment.end === 1 && segment.foreground === markerColor));
98
118
  return {
99
119
  ...line,
100
- text: line.text.startsWith(" ") ? `${TRUNCATED_PREVIEW_MARKER}${line.text.slice(2)}` : `${TRUNCATED_PREVIEW_MARKER}${line.text}`,
101
- segments: [{ start: 0, end: 1, foreground: markerColor }, ...(line.segments ?? [])],
120
+ text,
121
+ segments: [{ start: 0, end: 1, foreground: markerColor }, ...existingSegments],
102
122
  };
103
123
  });
104
124
  }
105
- function renderToolBodyLines(text, width, target, color, style, colors, syntaxHighlight, bodyWrap = "char", hasLspDiagnostics = false, bodyLineStyles, preserveAnsi = false) {
106
- const bodyWidth = Math.max(1, width - 2);
125
+ function renderToolBodyLines(text, width, target, color, style, colors, syntaxHighlight, bodyWrap = "char", hasLspDiagnostics = false, bodyLineStyles, preserveAnsi = false, showGutter = false) {
126
+ const displayPrefix = showGutter ? toolBodyGutterPrefix() : TOOL_BODY_PREFIX;
127
+ const prefixWidth = stringDisplayWidth(displayPrefix);
128
+ const bodyWidth = Math.max(1, width - prefixWidth);
107
129
  const lines = [];
108
130
  const wrapBodyLine = bodyWrap === "word" ? wrapDisplayLineByWords : wrapLine;
131
+ const gutterSegment = showGutter
132
+ ? { start: 0, end: 1, foreground: colors.statusDotBase }
133
+ : undefined;
109
134
  for (const [rawLineIndex, rawLine] of sanitizeToolBodyText(text, preserveAnsi).split("\n").entries()) {
110
135
  const ansiLine = preserveAnsi ? ansiStyledLine(rawLine) : undefined;
111
136
  const displayLine = ansiLine?.text ?? rawLine;
@@ -118,37 +143,67 @@ function renderToolBodyLines(text, width, target, color, style, colors, syntaxHi
118
143
  : wrapBodyLine(displayLine, bodyWidth).map((wrapped) => ({ text: wrapped, segments: [] }));
119
144
  for (const [wrapIndex, wrapped] of wrappedLines.entries()) {
120
145
  const line = {
121
- text: ` ${wrapped.text}`,
122
- copyText: ` ${wrapped.text}`,
146
+ text: `${displayPrefix}${wrapped.text}`,
147
+ copyText: `${TOOL_BODY_PREFIX}${wrapped.text}`,
123
148
  ...(wrapIndex < wrappedLines.length - 1 ? { continuesOnNextLine: true } : {}),
124
149
  target,
125
150
  colorOverride: color,
126
151
  };
127
152
  if (diffStyle) {
128
- const segment = { start: 2, end: line.text.length, foreground: diffStyle.foreground };
153
+ const segment = { start: displayPrefix.length, end: line.text.length, foreground: diffStyle.foreground };
129
154
  if (diffStyle.bold != null)
130
155
  segment.bold = diffStyle.bold;
131
- line.segments = [segment];
156
+ line.segments = gutterSegment ? [gutterSegment, segment] : [segment];
132
157
  }
133
158
  else if (lspDiagnosticStyle?.kind === "alert" && wrapIndex === 0) {
134
- line.segments = [{ start: 2, end: 2 + lspDiagnosticStyle.length, foreground: colors.warning, bold: true }];
159
+ line.segments = [
160
+ ...(gutterSegment ? [gutterSegment] : []),
161
+ { start: displayPrefix.length, end: displayPrefix.length + lspDiagnosticStyle.length, foreground: colors.warning, bold: true },
162
+ ];
135
163
  }
136
164
  else if (lspDiagnosticStyle?.kind === "severity") {
137
- line.segments = [{ start: 2, end: line.text.length, foreground: lspDiagnosticStyle.foreground }];
165
+ line.segments = [
166
+ ...(gutterSegment ? [gutterSegment] : []),
167
+ { start: displayPrefix.length, end: line.text.length, foreground: lspDiagnosticStyle.foreground },
168
+ ];
138
169
  }
139
- else if (bodyLineStyle && line.text.length > 2) {
140
- line.segments = [{ start: 2, end: line.text.length, ...bodyLineStyle }];
170
+ else if (bodyLineStyle && line.text.length > displayPrefix.length) {
171
+ line.segments = [
172
+ ...(gutterSegment ? [gutterSegment] : []),
173
+ { start: displayPrefix.length, end: line.text.length, ...bodyLineStyle },
174
+ ];
141
175
  }
142
176
  else if (lineSyntaxHighlight) {
143
177
  const rawStart = wrapIndex === 0 ? lineSyntaxHighlight.startColumn ?? 0 : 0;
144
- line.syntaxHighlight = { language: lineSyntaxHighlight.language, start: Math.min(line.text.length, 2 + rawStart) };
178
+ line.syntaxHighlight = { language: lineSyntaxHighlight.language, start: Math.min(line.text.length, displayPrefix.length + rawStart) };
179
+ if (gutterSegment)
180
+ line.segments = [gutterSegment];
145
181
  }
146
182
  else if (wrapped.segments.length > 0) {
147
- line.segments = wrapped.segments.map((segment) => ({ ...segment, start: segment.start + 2, end: segment.end + 2 }));
183
+ line.segments = [
184
+ ...(gutterSegment ? [gutterSegment] : []),
185
+ ...wrapped.segments.map((segment) => ({
186
+ ...segment,
187
+ start: segment.start + displayPrefix.length,
188
+ end: segment.end + displayPrefix.length,
189
+ })),
190
+ ];
191
+ }
192
+ else if (gutterSegment) {
193
+ line.segments = [gutterSegment];
148
194
  }
149
195
  lines.push(line);
150
196
  }
151
197
  }
198
+ if (showGutter && lines.length > 0) {
199
+ const lastLine = lines.at(-1);
200
+ if (!lastLine)
201
+ return lines;
202
+ const gutterPrefix = toolBodyGutterPrefix();
203
+ if (lastLine.text.startsWith(gutterPrefix)) {
204
+ lastLine.text = `${toolBodyEndPrefix()}${lastLine.text.slice(gutterPrefix.length)}`;
205
+ }
206
+ }
152
207
  return lines;
153
208
  }
154
209
  const ANSI_STANDARD_COLORS = ["#000000", "#cd3131", "#0dbc79", "#e5e510", "#2472c8", "#bc3fbc", "#11a8cd", "#e5e5e5"];
@@ -11,6 +11,8 @@ export function setFileLinkOpenerTestDeps(overrides) {
11
11
  };
12
12
  }
13
13
  export function openFileLink(link) {
14
+ if (isWebUrl(link.url))
15
+ return openPathWithSystemViewer(link.url);
14
16
  const filePath = link.filePath ?? filePathFromUrl(link.url);
15
17
  if (!filePath)
16
18
  return false;
@@ -19,6 +21,9 @@ export function openFileLink(link) {
19
21
  return true;
20
22
  return openPathWithSystemViewer(filePath);
21
23
  }
24
+ function isWebUrl(url) {
25
+ return url.startsWith("http://") || url.startsWith("https://");
26
+ }
22
27
  function filePathFromUrl(url) {
23
28
  if (!url.startsWith("file://"))
24
29
  return undefined;
@@ -3,10 +3,11 @@ import { homedir } from "node:os";
3
3
  import { isAbsolute, resolve } from "node:path";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  const FILE_PATH_CANDIDATE = /(?<![\p{L}\p{N}_:])((?:file:\/\/\/|~[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[\\/]|[A-Za-z0-9_.@-]+[\\/])[^\s"'`<>]*)/gu;
6
+ const WEB_URL_CANDIDATE = /https?:\/\/[^\s"'`<>]+/gu;
6
7
  const TRAILING_PUNCTUATION = new Set([".", ",", ";", ")", "]", "}"]);
7
8
  export function detectFileLinks(text, cwd) {
8
9
  const links = [];
9
- if (!text.includes("/") && !text.includes("\\"))
10
+ if (!text.includes("/") && !text.includes("\\") && !text.includes("http://") && !text.includes("https://"))
10
11
  return links;
11
12
  for (const match of text.matchAll(FILE_PATH_CANDIDATE)) {
12
13
  const raw = match[1];
@@ -28,6 +29,17 @@ export function detectFileLinks(text, cwd) {
28
29
  column: location.column,
29
30
  });
30
31
  }
32
+ for (const match of text.matchAll(WEB_URL_CANDIDATE)) {
33
+ const raw = match[0];
34
+ const candidate = trimTrailingPunctuation(raw);
35
+ if (!candidate)
36
+ continue;
37
+ links.push({
38
+ start: match.index,
39
+ end: match.index + candidate.length,
40
+ url: candidate,
41
+ });
42
+ }
31
43
  return mergeOverlappingLinks(links);
32
44
  }
33
45
  export function hyperlink(text, url) {
@@ -124,6 +124,7 @@ export declare class AppMouseController {
124
124
  private renderedConversationFrame;
125
125
  constructor(host: AppMouseControllerHost, popupMenus: AppPopupMenuController, popupActions: AppPopupActionController, scrollController: AppScrollController, commandController: AppCommandController);
126
126
  handleMouse(event: MouseEvent): void;
127
+ private toolTargetContainsEvent;
127
128
  activeClickFlash(): ClickFlash | undefined;
128
129
  consumeClickFlashDirty(): boolean;
129
130
  private withClickFlash;
@@ -133,10 +134,12 @@ export declare class AppMouseController {
133
134
  private clickFlashForEvent;
134
135
  private clickFlashForRegion;
135
136
  private clickFlashRegionForEvent;
137
+ private toolClickFlashRegionForEvent;
136
138
  private normalizedClickFlashRegion;
137
139
  private inputClickFlashRegionForEvent;
138
140
  private imageTargetAt;
139
141
  private fileLinkAt;
142
+ private fileLinkTargetAt;
140
143
  private statusTargetAt;
141
144
  private handleImageClick;
142
145
  private handleFileLinkClick;
@@ -7,6 +7,7 @@ import { sliceByDisplayColumns, stringDisplayWidth } from "../../terminal-width.
7
7
  import { formatDcpStatsToast } from "../rendering/dcp-stats.js";
8
8
  import { detectFileLinks } from "./file-links.js";
9
9
  import { openFileLink as openDetectedFileLink } from "./file-link-opener.js";
10
+ import { APP_ICONS } from "../icons.js";
10
11
  const CLICK_FLASH_MS = 100;
11
12
  const LOST_MOUSE_RELEASE_SETTLE_MS = 180;
12
13
  export class AppMouseController {
@@ -59,6 +60,8 @@ export class AppMouseController {
59
60
  this.showClickFlashOnPress(event);
60
61
  if (event.button === 0 && !event.released && this.handleInputBorderStatusClick(event))
61
62
  return;
63
+ if (event.button === 0 && !event.released && this.fileLinkAt(event))
64
+ return;
62
65
  if (this.handleMouseSelection(event))
63
66
  return;
64
67
  if (this.withClickFlash(event, () => this.handleImageClick(event)))
@@ -151,6 +154,8 @@ export class AppMouseController {
151
154
  return;
152
155
  }
153
156
  if (target?.kind === "tool") {
157
+ if (!this.toolTargetContainsEvent(event))
158
+ return;
154
159
  const entry = this.host.findEntry(target.id);
155
160
  if (entry?.kind === "tool" || entry?.kind === "thinking" || entry?.kind === "shell") {
156
161
  entry.expanded = !entry.expanded;
@@ -159,6 +164,16 @@ export class AppMouseController {
159
164
  }
160
165
  }
161
166
  }
167
+ toolTargetContainsEvent(event) {
168
+ const text = this.renderedRowTexts.get(event.y) ?? "";
169
+ const gutter = toolGutterGlyphForLine(text);
170
+ if (gutter) {
171
+ const gutterWidth = Math.max(1, stringDisplayWidth(gutter));
172
+ return event.x >= 1 && event.x < 1 + gutterWidth;
173
+ }
174
+ const bounds = nonBlankLineBounds(text, event.x);
175
+ return event.x >= bounds.startColumn && event.x < bounds.endColumn;
176
+ }
162
177
  activeClickFlash() {
163
178
  return this.clickFlash;
164
179
  }
@@ -221,16 +236,19 @@ export class AppMouseController {
221
236
  const imageTarget = this.imageTargetAt(event);
222
237
  if (imageTarget)
223
238
  return { y: event.y, startColumn: imageTarget.start + 1, endColumn: imageTarget.end + 1 };
224
- const link = this.fileLinkAt(event);
225
- if (link)
226
- return { y: event.y, startColumn: link.start + 1, endColumn: link.end + 1 };
239
+ const linkTarget = this.fileLinkTargetAt(event);
240
+ if (linkTarget)
241
+ return { y: event.y, startColumn: linkTarget.startColumn, endColumn: linkTarget.endColumn };
227
242
  const tabTarget = this.tabLineTargetAt(event);
228
243
  if (tabTarget)
229
244
  return { y: tabTarget.row, startColumn: tabTarget.startColumn, endColumn: tabTarget.endColumn };
230
245
  const statusTarget = this.statusTargetAt(event);
231
246
  if (statusTarget)
232
247
  return statusTarget;
233
- const toastTarget = this.renderedTargets.get(event.y);
248
+ const target = this.renderedTargets.get(event.y);
249
+ if (target?.kind === "tool")
250
+ return this.toolClickFlashRegionForEvent(event);
251
+ const toastTarget = target;
234
252
  if (toastTarget?.kind === "toast" && toastTargetContainsEvent(toastTarget, event)) {
235
253
  return {
236
254
  y: event.y,
@@ -244,6 +262,19 @@ export class AppMouseController {
244
262
  }
245
263
  return undefined;
246
264
  }
265
+ toolClickFlashRegionForEvent(event) {
266
+ const text = this.renderedRowTexts.get(event.y) ?? "";
267
+ const gutter = toolGutterGlyphForLine(text);
268
+ if (gutter) {
269
+ const gutterWidth = Math.max(1, stringDisplayWidth(gutter));
270
+ const region = { y: event.y, startColumn: 1, endColumn: 1 + gutterWidth };
271
+ return event.x >= region.startColumn && event.x < region.endColumn ? region : undefined;
272
+ }
273
+ const bounds = nonBlankLineBounds(text, event.x);
274
+ return event.x >= bounds.startColumn && event.x < bounds.endColumn
275
+ ? { y: event.y, startColumn: bounds.startColumn, endColumn: bounds.endColumn }
276
+ : undefined;
277
+ }
247
278
  normalizedClickFlashRegion(region) {
248
279
  const columns = Math.max(1, this.host.terminalColumns());
249
280
  const y = Math.max(1, region.y);
@@ -261,10 +292,19 @@ export class AppMouseController {
261
292
  return targets?.find((candidate) => event.x >= candidate.start + 1 && event.x <= candidate.end);
262
293
  }
263
294
  fileLinkAt(event) {
295
+ return this.fileLinkTargetAt(event)?.link;
296
+ }
297
+ fileLinkTargetAt(event) {
264
298
  const text = this.renderedRowTexts.get(event.y);
265
299
  if (!text)
266
300
  return undefined;
267
- return detectFileLinks(text, this.host.cwd()).find((candidate) => event.x >= candidate.start + 1 && event.x <= candidate.end);
301
+ for (const link of detectFileLinks(text, this.host.cwd())) {
302
+ const startColumn = stringDisplayWidth(text.slice(0, link.start)) + 1;
303
+ const endColumn = startColumn + stringDisplayWidth(text.slice(link.start, link.end));
304
+ if (event.x >= startColumn && event.x < endColumn)
305
+ return { link, startColumn, endColumn };
306
+ }
307
+ return undefined;
268
308
  }
269
309
  statusTargetAt(event) {
270
310
  const target = [
@@ -294,7 +334,7 @@ export class AppMouseController {
294
334
  };
295
335
  }
296
336
  handleImageClick(event) {
297
- if (event.button !== 0 || !event.released)
337
+ if (!isPrimaryButtonRelease(event))
298
338
  return false;
299
339
  const imageTarget = this.imageTargetAt(event);
300
340
  if (!imageTarget)
@@ -312,7 +352,7 @@ export class AppMouseController {
312
352
  }
313
353
  handleFileLinkClick(event) {
314
354
  const modifiedPress = isModifiedPrimaryButton(event.button) && !event.released;
315
- const plainRelease = event.button === 0 && event.released;
355
+ const plainRelease = isPrimaryButtonRelease(event);
316
356
  if (!modifiedPress && !plainRelease)
317
357
  return false;
318
358
  const link = this.fileLinkAt(event);
@@ -995,6 +1035,13 @@ function nonBlankLineBounds(text, fallbackColumn) {
995
1035
  ? { startColumn: fallbackColumn, endColumn: fallbackColumn + 1 }
996
1036
  : { startColumn, endColumn };
997
1037
  }
1038
+ function toolGutterGlyphForLine(text) {
1039
+ for (const glyph of [APP_ICONS.toolBodyGutter, APP_ICONS.toolBodyEnd, APP_ICONS.toolPreviewTruncated]) {
1040
+ if (text.startsWith(`${glyph} `))
1041
+ return glyph;
1042
+ }
1043
+ return undefined;
1044
+ }
998
1045
  function displayCellAtColumn(text, column) {
999
1046
  if (column < 1)
1000
1047
  return " ";
@@ -1017,6 +1064,9 @@ function isModifiedPrimaryButton(button) {
1017
1064
  const modifierBits = button & (8 | 16);
1018
1065
  return primaryButton && modifierBits !== 0;
1019
1066
  }
1067
+ function isPrimaryButtonRelease(event) {
1068
+ return event.released && (event.button === 0 || (event.button & 3) === 3);
1069
+ }
1020
1070
  function editorLayoutRows(terminalRows, tabPanelRows) {
1021
1071
  return Math.max(1, terminalRows - tabPanelRows);
1022
1072
  }
@@ -70,6 +70,10 @@ export function firstUserMessageText(ctx: ExtensionContext): string | undefined
70
70
  return undefined;
71
71
  }
72
72
 
73
+ function hasExistingUserMessage(ctx: ExtensionContext): boolean {
74
+ return firstUserMessageText(ctx) !== undefined;
75
+ }
76
+
73
77
  export function fallbackSessionTitleFromInput(input: string, maxTitleChars: number): string | undefined {
74
78
  const normalized = input
75
79
  .replace(/[\t\r\n]+/gu, " ")
@@ -371,24 +375,6 @@ export default function sessionTitle(pi: ExtensionAPI) {
371
375
  })();
372
376
  }
373
377
 
374
- function primeTitleGenerationFromExistingSession(ctx: ExtensionContext, currentConfig: SessionTitleConfig): void {
375
- if (currentSessionName(ctx)) return;
376
-
377
- const input = firstUserMessageText(ctx);
378
- if (!input) return;
379
- if (!currentConfig.enabled) {
380
- applyFallbackSessionTitle(ctx, currentConfig, input);
381
- return;
382
- }
383
-
384
- pendingGeneration = {
385
- sessionId: ctx.sessionManager.getSessionId(),
386
- input: truncateInput(input, currentConfig.maxInputChars),
387
- attempts: 0,
388
- };
389
- startTitleGeneration(ctx, currentConfig);
390
- }
391
-
392
378
  function isSameSessionPath(left: string | undefined, right: string | undefined): boolean {
393
379
  if (!left || !right) return false;
394
380
  if (left === right) return true;
@@ -447,7 +433,6 @@ export default function sessionTitle(pi: ExtensionAPI) {
447
433
  await prepareForkTitleState(event, ctx);
448
434
  refreshSessionUi(ctx, { force: true });
449
435
  scheduleSessionUiRefresh(ctx);
450
- if (!forkTitleState) primeTitleGenerationFromExistingSession(ctx, config);
451
436
  });
452
437
 
453
438
  pi.on("session_shutdown", async () => {
@@ -480,6 +465,10 @@ export default function sessionTitle(pi: ExtensionAPI) {
480
465
  sessionId = currentSessionId;
481
466
  const currentName = currentSessionName(ctx);
482
467
  const activeForkTitleState = forkTitleState?.sessionId === currentSessionId ? forkTitleState : undefined;
468
+ if (!activeForkTitleState && hasExistingUserMessage(ctx)) {
469
+ forkTitleState = undefined;
470
+ return { action: "continue" as const };
471
+ }
483
472
  if (currentName && (!activeForkTitleState || currentName !== activeForkTitleState.inheritedSessionName)) {
484
473
  forkTitleState = undefined;
485
474
  return { action: "continue" as const };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ui-extend",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {