pi-ui-extend 0.1.25 → 0.1.27

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"];
@@ -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,6 +134,7 @@ 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;
@@ -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 {
@@ -151,6 +152,8 @@ export class AppMouseController {
151
152
  return;
152
153
  }
153
154
  if (target?.kind === "tool") {
155
+ if (!this.toolTargetContainsEvent(event))
156
+ return;
154
157
  const entry = this.host.findEntry(target.id);
155
158
  if (entry?.kind === "tool" || entry?.kind === "thinking" || entry?.kind === "shell") {
156
159
  entry.expanded = !entry.expanded;
@@ -159,6 +162,16 @@ export class AppMouseController {
159
162
  }
160
163
  }
161
164
  }
165
+ toolTargetContainsEvent(event) {
166
+ const text = this.renderedRowTexts.get(event.y) ?? "";
167
+ const gutter = toolGutterGlyphForLine(text);
168
+ if (gutter) {
169
+ const gutterWidth = Math.max(1, stringDisplayWidth(gutter));
170
+ return event.x >= 1 && event.x < 1 + gutterWidth;
171
+ }
172
+ const bounds = nonBlankLineBounds(text, event.x);
173
+ return event.x >= bounds.startColumn && event.x < bounds.endColumn;
174
+ }
162
175
  activeClickFlash() {
163
176
  return this.clickFlash;
164
177
  }
@@ -230,7 +243,10 @@ export class AppMouseController {
230
243
  const statusTarget = this.statusTargetAt(event);
231
244
  if (statusTarget)
232
245
  return statusTarget;
233
- const toastTarget = this.renderedTargets.get(event.y);
246
+ const target = this.renderedTargets.get(event.y);
247
+ if (target?.kind === "tool")
248
+ return this.toolClickFlashRegionForEvent(event);
249
+ const toastTarget = target;
234
250
  if (toastTarget?.kind === "toast" && toastTargetContainsEvent(toastTarget, event)) {
235
251
  return {
236
252
  y: event.y,
@@ -244,6 +260,19 @@ export class AppMouseController {
244
260
  }
245
261
  return undefined;
246
262
  }
263
+ toolClickFlashRegionForEvent(event) {
264
+ const text = this.renderedRowTexts.get(event.y) ?? "";
265
+ const gutter = toolGutterGlyphForLine(text);
266
+ if (gutter) {
267
+ const gutterWidth = Math.max(1, stringDisplayWidth(gutter));
268
+ const region = { y: event.y, startColumn: 1, endColumn: 1 + gutterWidth };
269
+ return event.x >= region.startColumn && event.x < region.endColumn ? region : undefined;
270
+ }
271
+ const bounds = nonBlankLineBounds(text, event.x);
272
+ return event.x >= bounds.startColumn && event.x < bounds.endColumn
273
+ ? { y: event.y, startColumn: bounds.startColumn, endColumn: bounds.endColumn }
274
+ : undefined;
275
+ }
247
276
  normalizedClickFlashRegion(region) {
248
277
  const columns = Math.max(1, this.host.terminalColumns());
249
278
  const y = Math.max(1, region.y);
@@ -995,6 +1024,13 @@ function nonBlankLineBounds(text, fallbackColumn) {
995
1024
  ? { startColumn: fallbackColumn, endColumn: fallbackColumn + 1 }
996
1025
  : { startColumn, endColumn };
997
1026
  }
1027
+ function toolGutterGlyphForLine(text) {
1028
+ for (const glyph of [APP_ICONS.toolBodyGutter, APP_ICONS.toolBodyEnd, APP_ICONS.toolPreviewTruncated]) {
1029
+ if (text.startsWith(`${glyph} `))
1030
+ return glyph;
1031
+ }
1032
+ return undefined;
1033
+ }
998
1034
  function displayCellAtColumn(text, column) {
999
1035
  if (column < 1)
1000
1036
  return " ";
@@ -363,6 +363,9 @@ export class AppSessionEventController {
363
363
  }
364
364
  if (!this.assistantTextBuffer)
365
365
  return visibleText;
366
+ if (!final && shouldHoldAssistantStreamWhitespaceTail(this.assistantTextBuffer, this.hasVisibleAssistantText(visibleText))) {
367
+ return visibleText;
368
+ }
366
369
  if (shouldHoldAssistantStreamTail(this.assistantTextBuffer, this.hasVisibleAssistantText(visibleText))) {
367
370
  if (final)
368
371
  this.assistantTextBuffer = "";
@@ -445,6 +448,9 @@ function shouldHoldAssistantStreamTail(text, hasVisibleText) {
445
448
  return !hasVisibleText;
446
449
  return isPotentialDcpMetadataLine(text);
447
450
  }
451
+ function shouldHoldAssistantStreamWhitespaceTail(text, hasVisibleText) {
452
+ return hasVisibleText && text.trim().length === 0;
453
+ }
448
454
  function isHiddenMarkdownMetadataLine(line) {
449
455
  return isMarkdownReferenceDefinition(line) || isPotentialDcpMetadataLine(line);
450
456
  }
@@ -41,8 +41,10 @@ export declare class AppTabsController {
41
41
  private readonly runtimeLoadsByTabId;
42
42
  private readonly runtimeSubscriptionsByTabId;
43
43
  private readonly runtimeRefreshTimersByTabId;
44
+ private readonly historyReloadTimersByTabId;
44
45
  private readonly inputStatesByTabId;
45
46
  private readonly deferredUserMessagesByTabId;
47
+ private readonly tabIdsNeedingHistoryReload;
46
48
  private activeTabId;
47
49
  private pendingActiveTabId;
48
50
  private historyLoadGeneration;
@@ -89,6 +91,9 @@ export declare class AppTabsController {
89
91
  private shouldScheduleDelayedSyncForRuntimeEvent;
90
92
  private scheduleDelayedRuntimeSync;
91
93
  private clearRuntimeRefreshTimers;
94
+ private clearHistoryReloadTimers;
95
+ private scheduleDelayedHistoryReload;
96
+ private reloadActiveTabHistoryIfNeeded;
92
97
  private syncTabFromObservedRuntime;
93
98
  private storeActiveInputState;
94
99
  private storeActiveDeferredUserMessages;
@@ -20,8 +20,10 @@ export class AppTabsController {
20
20
  runtimeLoadsByTabId = new Map();
21
21
  runtimeSubscriptionsByTabId = new Map();
22
22
  runtimeRefreshTimersByTabId = new Map();
23
+ historyReloadTimersByTabId = new Map();
23
24
  inputStatesByTabId = new Map();
24
25
  deferredUserMessagesByTabId = new Map();
26
+ tabIdsNeedingHistoryReload = new Set();
25
27
  activeTabId;
26
28
  pendingActiveTabId;
27
29
  historyLoadGeneration = 0;
@@ -632,6 +634,7 @@ export class AppTabsController {
632
634
  void this.saveTabs();
633
635
  this.scheduleTabPrewarm();
634
636
  await this.loadActiveSessionHistory(targetRuntime);
637
+ this.scheduleDelayedHistoryReload(target.id, targetRuntime);
635
638
  }
636
639
  async closeTab(tabId) {
637
640
  if (this.pendingActiveTabId) {
@@ -814,6 +817,8 @@ export class AppTabsController {
814
817
  this.runtimesByTabId.delete(tabId);
815
818
  this.runtimeLoadsByTabId.delete(tabId);
816
819
  this.clearRuntimeRefreshTimers(tabId);
820
+ this.clearHistoryReloadTimers(tabId);
821
+ this.tabIdsNeedingHistoryReload.delete(tabId);
817
822
  const subscription = this.runtimeSubscriptionsByTabId.get(tabId);
818
823
  subscription?.unsubscribe();
819
824
  this.runtimeSubscriptionsByTabId.delete(tabId);
@@ -822,6 +827,9 @@ export class AppTabsController {
822
827
  for (const tabId of this.runtimeRefreshTimersByTabId.keys()) {
823
828
  this.clearRuntimeRefreshTimers(tabId);
824
829
  }
830
+ for (const tabId of this.historyReloadTimersByTabId.keys()) {
831
+ this.clearHistoryReloadTimers(tabId);
832
+ }
825
833
  for (const subscription of this.runtimeSubscriptionsByTabId.values()) {
826
834
  subscription.unsubscribe();
827
835
  }
@@ -835,6 +843,7 @@ export class AppTabsController {
835
843
  const unsubscribe = runtime.session.subscribe((event) => {
836
844
  if (this.shouldScheduleDelayedSyncForRuntimeEvent(event)) {
837
845
  this.scheduleDelayedRuntimeSync(tabId, runtime);
846
+ this.tabIdsNeedingHistoryReload.add(tabId);
838
847
  }
839
848
  if (!this.shouldSyncTabFromRuntimeEvent(event))
840
849
  return;
@@ -878,6 +887,44 @@ export class AppTabsController {
878
887
  clearTimeout(timer);
879
888
  this.runtimeRefreshTimersByTabId.delete(tabId);
880
889
  }
890
+ clearHistoryReloadTimers(tabId) {
891
+ const timers = this.historyReloadTimersByTabId.get(tabId);
892
+ if (!timers)
893
+ return;
894
+ for (const timer of timers)
895
+ clearTimeout(timer);
896
+ this.historyReloadTimersByTabId.delete(tabId);
897
+ }
898
+ scheduleDelayedHistoryReload(tabId, runtime) {
899
+ if (!this.tabIdsNeedingHistoryReload.has(tabId))
900
+ return;
901
+ if (tabId !== this.activeTabId || this.pendingActiveTabId !== undefined)
902
+ return;
903
+ this.clearHistoryReloadTimers(tabId);
904
+ for (const delayMs of [150, 1000, 3000]) {
905
+ const timer = setTimeout(() => {
906
+ this.historyReloadTimersByTabId.get(tabId)?.delete(timer);
907
+ void this.reloadActiveTabHistoryIfNeeded(tabId, runtime, delayMs === 3000);
908
+ }, delayMs);
909
+ timer.unref?.();
910
+ let timers = this.historyReloadTimersByTabId.get(tabId);
911
+ if (!timers) {
912
+ timers = new Set();
913
+ this.historyReloadTimersByTabId.set(tabId, timers);
914
+ }
915
+ timers.add(timer);
916
+ }
917
+ }
918
+ async reloadActiveTabHistoryIfNeeded(tabId, runtime, finalAttempt) {
919
+ if (tabId !== this.activeTabId || this.pendingActiveTabId !== undefined || this.host.runtime() !== runtime)
920
+ return;
921
+ if (!this.tabIdsNeedingHistoryReload.has(tabId))
922
+ return;
923
+ await this.loadActiveSessionHistory(runtime);
924
+ if (finalAttempt && tabId === this.activeTabId && this.host.runtime() === runtime) {
925
+ this.tabIdsNeedingHistoryReload.delete(tabId);
926
+ }
927
+ }
881
928
  syncTabFromObservedRuntime(tabId, runtime) {
882
929
  const tab = this.tabItems.find((item) => item.id === tabId);
883
930
  if (!tab) {
@@ -40,6 +40,7 @@ const DEFAULT_LOOKUP_TIMEOUT_MS = 120_000;
40
40
  const MAX_IMAGE_BYTES = 16 * 1024 * 1024;
41
41
  const SILENCE_REMINDER_MIN_VIOLATION_GAP = 3;
42
42
  const SILENCE_REMINDER_MIN_MESSAGE_GAP = 12;
43
+ const LOOKUP_TOOL_NAME = "lookup";
43
44
 
44
45
  const LOOKUP_TOOL_PARAMS = Type.Object(
45
46
  {
@@ -187,16 +188,35 @@ export default function glmCodingDiscipline(pi: ExtensionAPI) {
187
188
  pi.registerTool(createLookupTool());
188
189
  }
189
190
 
191
+ function syncLookupToolAvailability(modelRef: string | undefined, cwd?: string): void {
192
+ const activeTools = typeof pi.getActiveTools === "function" ? pi.getActiveTools() : undefined;
193
+ if (!Array.isArray(activeTools)) return;
194
+
195
+ const lookupEnabled = Boolean(lookupModelFromConfig(cwd));
196
+ const shouldExposeLookup = lookupEnabled && isGlmModel(modelRef);
197
+ const hasLookup = activeTools.includes(LOOKUP_TOOL_NAME);
198
+
199
+ if (shouldExposeLookup === hasLookup) return;
200
+ if (typeof pi.setActiveTools !== "function") return;
201
+
202
+ const nextTools = shouldExposeLookup
203
+ ? [...activeTools, LOOKUP_TOOL_NAME]
204
+ : activeTools.filter((tool: unknown) => tool !== LOOKUP_TOOL_NAME);
205
+ pi.setActiveTools([...new Set(nextTools)]);
206
+ }
207
+
190
208
  maybeRegisterLookupTool(process.cwd());
191
209
 
192
210
  pi.on("session_start", async (_event: unknown, ctx: unknown) => {
193
211
  selectedModelRef = modelRefFromContext(ctx);
194
212
  maybeRegisterLookupTool(contextCwd(ctx));
213
+ syncLookupToolAvailability(selectedModelRef, contextCwd(ctx));
195
214
  });
196
215
 
197
216
  pi.on("model_select", async (event: { model?: unknown }, ctx: unknown) => {
198
217
  selectedModelRef = modelRefFromModel(event.model) ?? modelRefFromContext(ctx);
199
218
  maybeRegisterLookupTool(contextCwd(ctx));
219
+ syncLookupToolAvailability(selectedModelRef, contextCwd(ctx));
200
220
  });
201
221
 
202
222
  pi.on("before_provider_request", async (event: { payload?: unknown }, ctx: unknown) => {
@@ -273,7 +293,7 @@ export function injectCodingDisciplineIntoPayload(payload: unknown, options: { l
273
293
 
274
294
  function createLookupTool() {
275
295
  return {
276
- name: "lookup",
296
+ name: LOOKUP_TOOL_NAME,
277
297
  label: "Lookup",
278
298
  description: [
279
299
  "Ask the configured vision-capable lookup model to inspect recent image/screenshot context and answer a focused visual question.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ui-extend",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {