pi-ui-extend 1.0.7 → 1.0.10

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.
@@ -394,16 +394,14 @@ export class ExtensionUiController {
394
394
  if (!this.host.isRunning())
395
395
  return undefined;
396
396
  const scopeKey = this.normalizeScopeKey(options.scopeKey);
397
- if (!this.isScopeActive(scopeKey))
398
- return undefined;
399
397
  if (this.activeCustomUis.has(scopeKey))
400
398
  throw new Error("Another extension custom UI is already active.");
401
- const savedInput = options.savedInput ?? this.host.getInput();
399
+ const savedInput = options.savedInput ?? (this.isScopeActive(scopeKey) ? this.host.getInput() : undefined);
402
400
  return await new Promise((resolve, reject) => {
403
401
  const active = {
404
402
  key: CUSTOM_UI_WIDGET_KEY,
405
403
  scopeKey,
406
- savedInput,
404
+ ...(savedInput === undefined ? {} : { savedInput }),
407
405
  settled: false,
408
406
  resolve: (value) => resolve(value),
409
407
  reject,
@@ -414,20 +412,17 @@ export class ExtensionUiController {
414
412
  const done = (value) => {
415
413
  if (active.settled || this.activeCustomUis.get(scopeKey) !== active)
416
414
  return;
417
- this.finishActiveCustomUi(active, this.isScopeActive(scopeKey) ? value : undefined, { resolve: true });
415
+ this.finishActiveCustomUi(active, value, { resolve: true });
418
416
  };
419
417
  void (async () => {
420
418
  try {
421
419
  const component = await factory(this.widgetTuiHandle(scopeKey), this.createExtensionTheme(), {}, done);
422
- if (active.settled || this.activeCustomUis.get(scopeKey) !== active || !this.isScopeActive(scopeKey)) {
420
+ if (active.settled || this.activeCustomUis.get(scopeKey) !== active) {
423
421
  component.dispose?.();
424
- if (!active.settled && this.activeCustomUis.get(scopeKey) === active) {
425
- this.finishActiveCustomUi(active, undefined, { resolve: true });
426
- }
427
422
  return;
428
423
  }
429
424
  active.component = component;
430
- if (this.host.isRunning())
425
+ if (this.host.isRunning() && this.isScopeActive(scopeKey))
431
426
  this.host.render();
432
427
  }
433
428
  catch (error) {
@@ -454,8 +449,9 @@ export class ExtensionUiController {
454
449
  return;
455
450
  active.settled = true;
456
451
  this.activeCustomUis.delete(active.scopeKey);
457
- if (this.isScopeActive(active.scopeKey) && this.host.getInput() !== active.savedInput)
452
+ if (this.isScopeActive(active.scopeKey) && active.savedInput !== undefined && this.host.getInput() !== active.savedInput) {
458
453
  this.host.setInput(active.savedInput);
454
+ }
459
455
  try {
460
456
  active.component?.dispose?.();
461
457
  }
@@ -479,7 +475,10 @@ export class ExtensionUiController {
479
475
  const scopeKey = this.activeScopeKey();
480
476
  if (!this.isScopeActive(scopeKey))
481
477
  return undefined;
482
- return this.activeCustomUis.get(scopeKey);
478
+ const active = this.activeCustomUis.get(scopeKey);
479
+ if (active && active.savedInput === undefined)
480
+ active.savedInput = this.host.getInput();
481
+ return active;
483
482
  }
484
483
  isScopeActive(scopeKey) {
485
484
  return this.host.isExtensionUiScopeActive?.(scopeKey) ?? this.activeScopeKey() === scopeKey;
@@ -1,4 +1,5 @@
1
1
  import { resolveColor } from "../../config.js";
2
+ import { renderMarkdownLine } from "../../markdown-format.js";
2
3
  import { expandTabs, sliceByDisplayWidth, stringDisplayWidth, wrapDisplayLineByWords } from "../../terminal-width.js";
3
4
  import { alertIconPrefixLength, hasToolLspDiagnosticsAfterMutation, lspDiagnosticSeverityForLine, sanitizeText, toolStatusIcon, toolStatusIconColor, wrapLine } from "./render-text.js";
4
5
  import { APP_ICONS } from "../icons.js";
@@ -234,9 +235,16 @@ function renderToolBodyLines(text, width, target, color, style, colors, syntaxHi
234
235
  const lspDiagnosticStyle = hasLspDiagnostics ? lspDiagnosticLineStyle(displayLine, colors) : undefined;
235
236
  const bodyLineStyle = bodyLineStyleForLine(bodyLineStyles, absoluteRawLineIndex, colors);
236
237
  const lineSyntaxHighlight = syntaxHighlightForLine(syntaxHighlight, absoluteRawLineIndex);
238
+ const markdownLine = lineSyntaxHighlight?.language === "markdown"
239
+ ? renderMarkdownLine(displayLine, lineSyntaxHighlight.startColumn ?? 0)
240
+ : undefined;
241
+ const syntaxDisplayLine = markdownLine?.text ?? displayLine;
237
242
  const wrappedLines = ansiLine && !diffStyle && !lspDiagnosticStyle && !bodyLineStyle && !lineSyntaxHighlight
238
243
  ? wrapAnsiStyledDisplayLine(ansiLine, bodyWidth)
239
- : wrapBodyLine(displayLine, bodyWidth).map((wrapped) => ({ text: wrapped, segments: [] }));
244
+ : wrapBodyLine(syntaxDisplayLine, bodyWidth).map((wrapped) => ({ text: wrapped, segments: [] }));
245
+ const wrappedSourceRanges = lineSyntaxHighlight && wrappedLines.length > 1
246
+ ? sourceRangesForWrappedLines(syntaxDisplayLine, wrappedLines.map((line) => line.text))
247
+ : [];
240
248
  for (const [wrapIndex, wrapped] of wrappedLines.entries()) {
241
249
  const line = {
242
250
  text: `${displayPrefix}${wrapped.text}`,
@@ -271,9 +279,33 @@ function renderToolBodyLines(text, width, target, color, style, colors, syntaxHi
271
279
  }
272
280
  else if (lineSyntaxHighlight) {
273
281
  const rawStart = wrapIndex === 0 ? lineSyntaxHighlight.startColumn ?? 0 : 0;
274
- line.syntaxHighlight = { language: lineSyntaxHighlight.language, start: Math.min(line.text.length, displayPrefix.length + rawStart) };
275
- if (gutterSegment)
276
- line.segments = [gutterSegment];
282
+ const sourceRange = wrappedSourceRanges[wrapIndex];
283
+ const markdownSegments = sourceRange
284
+ ? markdownLine?.segments.flatMap((segment) => shiftStyledSegmentToRange(segment, sourceRange)) ?? []
285
+ : markdownLine?.segments ?? [];
286
+ line.syntaxHighlight = {
287
+ language: lineSyntaxHighlight.language,
288
+ start: Math.min(line.text.length, displayPrefix.length + rawStart),
289
+ ...(sourceRange ? {
290
+ context: {
291
+ text: syntaxDisplayLine,
292
+ rangeStart: sourceRange.start,
293
+ rangeEnd: sourceRange.end,
294
+ renderStart: displayPrefix.length,
295
+ syntaxStart: lineSyntaxHighlight.startColumn ?? 0,
296
+ },
297
+ } : {}),
298
+ };
299
+ const syntaxSegments = [
300
+ ...(gutterSegment ? [gutterSegment] : []),
301
+ ...markdownSegments.map((segment) => ({
302
+ ...segment,
303
+ start: segment.start + displayPrefix.length,
304
+ end: segment.end + displayPrefix.length,
305
+ })),
306
+ ];
307
+ if (syntaxSegments.length > 0)
308
+ line.segments = syntaxSegments;
277
309
  }
278
310
  else if (wrapped.segments.length > 0) {
279
311
  line.segments = [
@@ -302,6 +334,25 @@ function renderToolBodyLines(text, width, target, color, style, colors, syntaxHi
302
334
  }
303
335
  return lines;
304
336
  }
337
+ function shiftStyledSegmentToRange(segment, range) {
338
+ const start = Math.max(segment.start, range.start);
339
+ const end = Math.min(segment.end, range.end);
340
+ if (end <= start)
341
+ return [];
342
+ return [{ ...segment, start: start - range.start, end: end - range.start }];
343
+ }
344
+ function sourceRangesForWrappedLines(text, wrappedLines) {
345
+ const ranges = [];
346
+ let cursor = 0;
347
+ for (const wrapped of wrappedLines) {
348
+ const found = text.indexOf(wrapped, cursor);
349
+ const start = found === -1 ? cursor : found;
350
+ const end = Math.min(text.length, start + wrapped.length);
351
+ ranges.push({ start, end });
352
+ cursor = end;
353
+ }
354
+ return ranges;
355
+ }
305
356
  const ANSI_STANDARD_COLORS = ["#000000", "#cd3131", "#0dbc79", "#e5e510", "#2472c8", "#bc3fbc", "#11a8cd", "#e5e5e5"];
306
357
  const ANSI_BRIGHT_COLORS = ["#666666", "#f14c4c", "#23d18b", "#f5f543", "#3b8eea", "#d670d6", "#29b8db", "#e5e5e5"];
307
358
  function sanitizeToolBodyText(text, preserveAnsi) {
@@ -16,7 +16,7 @@ export class ScreenStyler {
16
16
  ...(line?.backgroundOverride === undefined ? {} : { background: line.backgroundOverride }),
17
17
  };
18
18
  const colors = this.host.theme.colors;
19
- const markdownLine = line?.syntaxHighlight?.language === "markdown"
19
+ const markdownLine = line?.syntaxHighlight?.language === "markdown" && !line.syntaxHighlight.context
20
20
  ? renderMarkdownDisplayLine(line.text, width, line.syntaxHighlight.start)
21
21
  : undefined;
22
22
  const text = markdownLine?.text ?? line?.text ?? "";
@@ -18,7 +18,7 @@ export default function questionExtension(pi) {
18
18
  if (!ctx.hasUI)
19
19
  return createQuestionToolResult(createCanceledQuestionResult("ui_unavailable", questions), questions);
20
20
  const selections = await runQuestionnaire(questions, ctx);
21
- if (selections === null)
21
+ if (selections == null)
22
22
  return createQuestionToolResult(createCanceledQuestionResult("user_canceled"), questions);
23
23
  return createQuestionToolResult(createSuccessfulQuestionResult(questions, selections), questions);
24
24
  },
@@ -1,2 +1,2 @@
1
1
  import type { NormalizedQuestion, QuestionSelection, QuestionUiContext } from "./types.js";
2
- export declare function runQuestionnaire(questions: NormalizedQuestion[], ctx: QuestionUiContext): Promise<QuestionSelection[] | null>;
2
+ export declare function runQuestionnaire(questions: NormalizedQuestion[], ctx: QuestionUiContext): Promise<QuestionSelection[] | null | undefined>;
@@ -61,7 +61,7 @@ export interface QuestionToolResult {
61
61
  export interface QuestionUiContext {
62
62
  hasUI?: boolean;
63
63
  ui: {
64
- custom<T>(factory: (tui: QuestionTui, theme: QuestionTheme, keybindings: unknown, done: (value: T) => void) => QuestionComponent): Promise<T>;
64
+ custom<T>(factory: (tui: QuestionTui, theme: QuestionTheme, keybindings: unknown, done: (value: T) => void) => QuestionComponent): Promise<T | undefined>;
65
65
  setEditorText?(text: string): void;
66
66
  getEditorText?(): string;
67
67
  notify?(message: string, level: "info" | "warning" | "error"): void;
@@ -9,6 +9,8 @@ export type RenderedMarkdownLine = {
9
9
  bold: true;
10
10
  }[];
11
11
  heading?: boolean;
12
+ sourceStart?: number;
13
+ sourceEnd?: number;
12
14
  };
13
15
  export type RenderedMarkdownTextLine = {
14
16
  text: string;
@@ -84,13 +84,26 @@ export function renderMarkdownTextLines(text, width, start = 0, options = {}) {
84
84
  const syntaxHighlight = markdownLineSyntaxHighlight(fence, Boolean(opensFence || closesFence), start);
85
85
  const isHeadingLine = !fence && /^\s{0,3}#{1,6}\s/.test(rawLine);
86
86
  const markdownLine = syntaxHighlight?.language === "markdown" || isHeadingLine ? renderMarkdownLine(rawLine) : undefined;
87
- for (const wrapped of wrapRenderedMarkdownLine(markdownLine ?? { text: rawLine, segments: [] }, width, options)) {
87
+ const logicalLine = markdownLine ?? { text: rawLine, segments: [] };
88
+ for (const wrapped of wrapRenderedMarkdownLine(logicalLine, width, options)) {
89
+ const wrappedSyntaxHighlight = syntaxHighlight && wrapped.sourceStart !== undefined && wrapped.sourceEnd !== undefined
90
+ ? {
91
+ ...syntaxHighlight,
92
+ context: {
93
+ text: logicalLine.text,
94
+ rangeStart: wrapped.sourceStart,
95
+ rangeEnd: wrapped.sourceEnd,
96
+ renderStart: start,
97
+ syntaxStart: 0,
98
+ },
99
+ }
100
+ : syntaxHighlight;
88
101
  lines.push({
89
102
  text: wrapped.text,
90
103
  ...(wrapped.copyText === undefined ? {} : { copyText: wrapped.copyText }),
91
104
  ...(wrapped.continuesOnNextLine ? { continuesOnNextLine: true } : {}),
92
105
  ...(wrapped.segments.length > 0 ? { segments: wrapped.segments } : {}),
93
- ...(syntaxHighlight ? { syntaxHighlight } : {}),
106
+ ...(wrappedSyntaxHighlight ? { syntaxHighlight: wrappedSyntaxHighlight } : {}),
94
107
  ...(isHeadingLine ? { heading: true } : {}),
95
108
  });
96
109
  }
@@ -133,6 +146,8 @@ function wrapRenderedMarkdownLine(line, width, options) {
133
146
  copyText: line.text.slice(range.start, ranges[index + 1]?.start ?? range.end),
134
147
  ...(index < ranges.length - 1 ? { continuesOnNextLine: true } : {}),
135
148
  segments: line.segments.flatMap((segment) => shiftSegmentToRange(segment, range.start, range.end)),
149
+ sourceStart: range.start,
150
+ sourceEnd: range.end,
136
151
  }));
137
152
  }
138
153
  function wrapDisplayLineByWordsWithRanges(text, width, options) {
@@ -3,6 +3,18 @@ export type SyntaxHighlightLanguage = "c" | "cpp" | "csharp" | "css" | "go" | "h
3
3
  export type SyntaxLineHighlight = {
4
4
  language: SyntaxHighlightLanguage;
5
5
  start: number;
6
+ context?: SyntaxHighlightContext;
7
+ };
8
+ export type SyntaxHighlightContext = {
9
+ /** Complete logical line, before it was soft-wrapped for display. */
10
+ text: string;
11
+ /** Source range represented by this visual line. */
12
+ rangeStart: number;
13
+ rangeEnd: number;
14
+ /** Visual column where rangeStart is rendered. */
15
+ renderStart: number;
16
+ /** Source column where syntax parsing begins. */
17
+ syntaxStart: number;
6
18
  };
7
19
  export type ToolBodySyntaxHighlight = {
8
20
  language: SyntaxHighlightLanguage;
@@ -112,12 +112,32 @@ export function syntaxHighlightLanguageForMarkdownFence(info) {
112
112
  return MARKDOWN_FENCE_LANGUAGES[token];
113
113
  }
114
114
  export function syntaxHighlightSegmentsForLine(text, highlight, colors) {
115
+ if (highlight.context)
116
+ return contextualSyntaxSegments(highlight.language, highlight.context, colors);
115
117
  const start = Math.max(0, Math.min(text.length, highlight.start));
116
118
  if (start >= text.length)
117
119
  return [];
118
120
  const segments = localSyntaxSegments(text.slice(start), highlight.language, colors);
119
121
  return segments.map((segment) => ({ ...segment, start: segment.start + start, end: segment.end + start }));
120
122
  }
123
+ function contextualSyntaxSegments(language, context, colors) {
124
+ const syntaxStart = Math.max(0, Math.min(context.text.length, context.syntaxStart));
125
+ const rangeStart = Math.max(0, Math.min(context.text.length, context.rangeStart));
126
+ const rangeEnd = Math.max(rangeStart, Math.min(context.text.length, context.rangeEnd));
127
+ const segments = localSyntaxSegments(context.text.slice(syntaxStart), language, colors)
128
+ .map((segment) => ({ ...segment, start: segment.start + syntaxStart, end: segment.end + syntaxStart }));
129
+ return segments.flatMap((segment) => {
130
+ const start = Math.max(segment.start, rangeStart);
131
+ const end = Math.min(segment.end, rangeEnd);
132
+ if (end <= start)
133
+ return [];
134
+ return [{
135
+ ...segment,
136
+ start: context.renderStart + start - rangeStart,
137
+ end: context.renderStart + end - rangeStart,
138
+ }];
139
+ });
140
+ }
121
141
  function localSyntaxSegments(code, language, colors) {
122
142
  switch (language) {
123
143
  case "css":
@@ -809,6 +809,7 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
809
809
  await saveDcpState(ctx, state)
810
810
  }
811
811
 
812
+ const anchorsBeforeFinalization = state.nudgeAnchors.length
812
813
  if (state.manualMode) {
813
814
  state.nudgeAnchors = state.nudgeAnchors.filter((anchor) =>
814
815
  anchor.type === "context-strong" || anchor.type === "context-soft",
@@ -817,6 +818,9 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
817
818
  applyAnchoredNudges(prunedMessages, state, (anchor) =>
818
819
  appendConcreteNudgeGuidance(baseNudgeText(anchor.type), candidate, messageCandidates, state),
819
820
  )
821
+ if (state.nudgeAnchors.length !== anchorsBeforeFinalization) {
822
+ await saveDcpState(ctx, state)
823
+ }
820
824
 
821
825
  return finishContext("complete", prunedMessages, {
822
826
  candidate,
@@ -51,7 +51,6 @@ function isRealAnchorCandidate(msg: any): boolean {
51
51
  const role = msg?.role ?? "";
52
52
  if (role !== "user" && role !== "assistant") return false;
53
53
  const text = messageText(msg);
54
- if (text.includes("<dcp-system-reminder>")) return false;
55
54
  if (extractBlockId(text) !== undefined) return false;
56
55
  return true;
57
56
  }
@@ -94,6 +93,12 @@ function anchorMatchesMessage(anchor: DcpNudgeAnchor, msg: any, index: number):
94
93
  return msg?.timestamp === anchor.anchorTimestamp;
95
94
  }
96
95
 
96
+ function isNewerAnchor(candidate: DcpNudgeAnchor, current: DcpNudgeAnchor): boolean {
97
+ if (candidate.updatedAt !== current.updatedAt) return candidate.updatedAt > current.updatedAt;
98
+ if (candidate.createdAt !== current.createdAt) return candidate.createdAt > current.createdAt;
99
+ return candidate.id > current.id;
100
+ }
101
+
97
102
  function appendTextToMessage(msg: any, text: string): void {
98
103
  const suffix = `\n\n${text}`;
99
104
  if (typeof msg.content === "string") {
@@ -205,15 +210,27 @@ export function upsertNudgeAnchor(
205
210
  options: { contextPercent?: number } = {},
206
211
  ): { anchor: DcpNudgeAnchor | null; created: boolean; updated: boolean } {
207
212
  const target = findAnchorMessage(messages);
208
- if (!target) return { anchor: null, created: false, updated: false };
213
+ if (!target) {
214
+ // The caller will append one synthetic tail reminder. Drop persisted
215
+ // anchors first so applyAnchoredNudges cannot render a second reminder.
216
+ state.nudgeAnchors = [];
217
+ state.lastNudge = undefined;
218
+ return { anchor: null, created: false, updated: false };
219
+ }
209
220
 
210
221
  const key = `${target.stableId}|${target.timestamp}`;
211
- const existing = state.nudgeAnchors.find(
212
- (anchor) => `${anchor.anchorStableId ?? ""}|${anchor.anchorTimestamp}` === key,
213
- );
222
+ let existing: DcpNudgeAnchor | null = null;
223
+ for (const anchor of state.nudgeAnchors) {
224
+ const anchorKey = `${anchor.anchorStableId ?? ""}|${anchor.anchorTimestamp}`;
225
+ if (anchorKey !== key) continue;
226
+ if (!existing || isNewerAnchor(anchor, existing)) existing = anchor;
227
+ }
214
228
 
215
229
  const now = Date.now();
216
230
  if (existing) {
231
+ // Older sidecars may contain several anchors. Keep only the anchor for the
232
+ // current target so every subsequent context pass has singleton state.
233
+ state.nudgeAnchors = [existing];
217
234
  const shouldUpgrade = typePriority(type) > typePriority(existing.type);
218
235
  if (shouldUpgrade) existing.type = type;
219
236
  existing.updatedAt = now;
@@ -240,7 +257,9 @@ export function upsertNudgeAnchor(
240
257
  createdAt: now,
241
258
  updatedAt: now,
242
259
  };
243
- state.nudgeAnchors.push(anchor);
260
+ // A nudge follows the latest useful message. Replacing the previous anchor
261
+ // prevents one full reminder from accumulating per turn/assistant response.
262
+ state.nudgeAnchors = [anchor];
244
263
  state.lastNudge = {
245
264
  type,
246
265
  anchorId: anchor.id,
@@ -259,15 +278,19 @@ export function applyAnchoredNudges(
259
278
  ): void {
260
279
  if (state.nudgeAnchors.length === 0) return;
261
280
 
262
- const activeAnchors: DcpNudgeAnchor[] = [];
281
+ let selected: { anchor: DcpNudgeAnchor; index: number } | null = null;
263
282
  for (const anchor of state.nudgeAnchors) {
264
283
  const index = messages.findIndex((msg, messageIndex) => anchorMatchesMessage(anchor, msg, messageIndex));
265
284
  if (index === -1) continue;
266
- activeAnchors.push(anchor);
267
- appendTextToMessage(messages[index], render(anchor));
285
+ if (!selected || isNewerAnchor(anchor, selected.anchor)) {
286
+ selected = { anchor, index };
287
+ }
268
288
  }
269
289
 
270
- state.nudgeAnchors = activeAnchors;
290
+ // Defensive migration for persisted pre-singleton state: render only the
291
+ // newest valid anchor and discard every stale predecessor.
292
+ state.nudgeAnchors = selected ? [selected.anchor] : [];
293
+ if (selected) appendTextToMessage(messages[selected.index], render(selected.anchor));
271
294
  }
272
295
 
273
296
  export function clearDcpNudgeAnchors(state: DcpState): number {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ui-extend",
3
- "version": "1.0.7",
3
+ "version": "1.0.10",
4
4
  "description": "Pix: a workspace-first terminal UI for Pi with tabs, readable tool activity, voice input, and bundled agent tools.",
5
5
  "private": false,
6
6
  "repository": {