pi-ui-extend 0.1.15 → 0.1.18

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.
Files changed (92) hide show
  1. package/apps/desktop-tauri/README.md +103 -0
  2. package/apps/desktop-tauri/bin/pix-desktop.mjs +89 -0
  3. package/dist/app/app.d.ts +2 -0
  4. package/dist/app/app.js +21 -6
  5. package/dist/app/commands/command-controller.js +1 -0
  6. package/dist/app/commands/command-host.d.ts +2 -0
  7. package/dist/app/commands/command-navigation-actions.d.ts +9 -0
  8. package/dist/app/commands/command-navigation-actions.js +62 -3
  9. package/dist/app/commands/command-registry.d.ts +1 -0
  10. package/dist/app/commands/command-registry.js +8 -0
  11. package/dist/app/constants.d.ts +0 -1
  12. package/dist/app/constants.js +0 -1
  13. package/dist/app/icons.d.ts +1 -0
  14. package/dist/app/icons.js +2 -0
  15. package/dist/app/input/input-action-controller.d.ts +1 -0
  16. package/dist/app/input/input-action-controller.js +5 -4
  17. package/dist/app/input/input-controller.d.ts +1 -0
  18. package/dist/app/input/input-controller.js +29 -0
  19. package/dist/app/input/input-paste-handler.d.ts +1 -1
  20. package/dist/app/input/input-paste-handler.js +6 -5
  21. package/dist/app/model/model-usage-status.js +4 -27
  22. package/dist/app/popup/menu-items-controller.d.ts +2 -0
  23. package/dist/app/popup/menu-items-controller.js +37 -14
  24. package/dist/app/popup/popup-menu-controller.js +30 -5
  25. package/dist/app/rendering/editor-panels.js +20 -9
  26. package/dist/app/rendering/popup-menu-renderer.d.ts +12 -0
  27. package/dist/app/rendering/popup-menu-renderer.js +151 -53
  28. package/dist/app/rendering/render-controller.js +29 -15
  29. package/dist/app/rendering/render-text.js +5 -2
  30. package/dist/app/rendering/status-line-renderer.d.ts +7 -0
  31. package/dist/app/rendering/status-line-renderer.js +191 -94
  32. package/dist/app/rendering/toast-controller.d.ts +1 -0
  33. package/dist/app/rendering/toast-controller.js +17 -0
  34. package/dist/app/screen/mouse-controller.d.ts +1 -0
  35. package/dist/app/screen/mouse-controller.js +17 -20
  36. package/dist/app/screen/scroll-controller.d.ts +1 -0
  37. package/dist/app/screen/scroll-controller.js +6 -0
  38. package/dist/app/screen/status-controller.js +2 -1
  39. package/dist/app/session/request-history.d.ts +4 -0
  40. package/dist/app/session/request-history.js +11 -0
  41. package/dist/app/session/session-search.js +10 -0
  42. package/dist/app/session/tabs-controller.d.ts +4 -4
  43. package/dist/app/session/tabs-controller.js +64 -6
  44. package/dist/app/todo/todo-model.d.ts +2 -2
  45. package/dist/app/todo/todo-model.js +15 -17
  46. package/dist/app/types.d.ts +12 -4
  47. package/dist/config.d.ts +1 -0
  48. package/dist/config.js +10 -1
  49. package/dist/default-pix-config.js +2 -0
  50. package/dist/fuzzy.d.ts +2 -0
  51. package/dist/fuzzy.js +27 -7
  52. package/dist/input-editor.d.ts +9 -0
  53. package/dist/input-editor.js +52 -0
  54. package/dist/schemas/pix-schema.d.ts +1 -0
  55. package/dist/schemas/pix-schema.js +1 -0
  56. package/dist/theme.js +6 -6
  57. package/dist/ui.d.ts +8 -0
  58. package/external/pi-tools-suite/README.md +2 -2
  59. package/external/pi-tools-suite/src/antigravity-auth/auth-store.ts +40 -5
  60. package/external/pi-tools-suite/src/antigravity-auth/commands.ts +1 -37
  61. package/external/pi-tools-suite/src/antigravity-auth/constants.ts +0 -2
  62. package/external/pi-tools-suite/src/antigravity-auth/index.ts +3 -16
  63. package/external/pi-tools-suite/src/antigravity-auth/oauth.ts +33 -17
  64. package/external/pi-tools-suite/src/antigravity-auth/status.ts +1 -1
  65. package/external/pi-tools-suite/src/antigravity-auth/stream.ts +4 -12
  66. package/external/pi-tools-suite/src/antigravity-auth/types.ts +21 -0
  67. package/external/pi-tools-suite/src/config.ts +43 -0
  68. package/external/pi-tools-suite/src/dcp/commands.ts +1 -1
  69. package/external/pi-tools-suite/src/dcp/index.ts +21 -1
  70. package/external/pi-tools-suite/src/dcp/state.ts +225 -3
  71. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +5 -0
  72. package/external/pi-tools-suite/src/index.ts +1 -0
  73. package/external/pi-tools-suite/src/telegram-mirror/README.md +168 -0
  74. package/external/pi-tools-suite/src/telegram-mirror/bot.ts +228 -0
  75. package/external/pi-tools-suite/src/telegram-mirror/events.ts +94 -0
  76. package/external/pi-tools-suite/src/telegram-mirror/format.ts +120 -0
  77. package/external/pi-tools-suite/src/telegram-mirror/index.ts +424 -0
  78. package/external/pi-tools-suite/src/telegram-mirror/ipc.ts +419 -0
  79. package/external/pi-tools-suite/src/telegram-mirror/multiplexer.ts +408 -0
  80. package/external/pi-tools-suite/src/telegram-mirror/renderer.ts +214 -0
  81. package/external/pi-tools-suite/src/todo/index.ts +3 -64
  82. package/external/pi-tools-suite/src/todo/state/persistence.ts +0 -1
  83. package/external/pi-tools-suite/src/todo/state/state-reducer.ts +7 -37
  84. package/external/pi-tools-suite/src/todo/todo.ts +2 -18
  85. package/external/pi-tools-suite/src/todo/tool/response-envelope.ts +2 -11
  86. package/external/pi-tools-suite/src/todo/tool/types.ts +0 -29
  87. package/external/pi-tools-suite/src/todo/view/format.ts +1 -3
  88. package/external/pi-tools-suite/src/tool-descriptions.ts +5 -4
  89. package/external/pi-tools-suite/src/usage/lib/google.ts +50 -30
  90. package/external/pi-tools-suite/src/usage/lib/types.ts +12 -2
  91. package/package.json +14 -3
  92. package/schemas/pix.json +5 -0
@@ -815,9 +815,9 @@ function formatDurationShort(resetAt, now) {
815
815
  const hours = Math.floor((totalMinutes % 1440) / 60);
816
816
  const minutes = totalMinutes % 60;
817
817
  if (days > 0)
818
- return `${days}d ${hours}h`;
818
+ return `${days}d${hours}h`;
819
819
  if (hours > 0)
820
- return `${hours}h ${minutes}m`;
820
+ return `${hours}h${minutes}m`;
821
821
  return `${minutes}m`;
822
822
  }
823
823
  function maskCredential(value) {
@@ -826,29 +826,6 @@ function maskCredential(value) {
826
826
  return visible ? "****" : "unknown";
827
827
  return `${visible.slice(0, 4)}****${visible.slice(-4)}`;
828
828
  }
829
- function formatUsageWindow(prefix, window, now) {
830
- const resetLabel = prefix === "H" ? formatResetTime(window.resetAt, now) : formatGlobalResetLabel(window.resetAt, now);
831
- return `${window.remainingPercent}% ${formatCompactProgressBar(window.remainingPercent)} ${resetLabel}`;
832
- }
833
- function formatGlobalResetLabel(resetAt, now) {
834
- if (resetAt <= now)
835
- return "reset";
836
- return resetAt - now <= DAY_SECONDS * 1000 ? formatResetTime(resetAt, now) : formatResetDate(resetAt, now);
837
- }
838
- function formatResetTime(resetAt, now) {
839
- if (resetAt <= now)
840
- return "reset";
841
- return new Date(resetAt).toLocaleTimeString("ru-RU", {
842
- hour: "2-digit",
843
- minute: "2-digit",
844
- hourCycle: "h23",
845
- });
846
- }
847
- function formatResetDate(resetAt, now) {
848
- if (resetAt <= now)
849
- return "reset";
850
- return new Date(resetAt).toLocaleDateString("ru-RU", {
851
- day: "2-digit",
852
- month: "2-digit",
853
- });
829
+ function formatUsageWindow(_prefix, window, now) {
830
+ return `${window.remainingPercent}% ${formatCompactProgressBar(window.remainingPercent)} ${formatDurationShort(window.resetAt, now)}`;
854
831
  }
@@ -11,6 +11,7 @@ export declare class AppMenuItemsController {
11
11
  private readonly host;
12
12
  private resumeMenuLoaderCache;
13
13
  private userMessageJumpItems;
14
+ private userMessageJumpLoading;
14
15
  constructor(host: AppMenuItemsControllerHost);
15
16
  parseSlashInput(text: string): import("../types.js").ParsedSlashInput | undefined;
16
17
  getResourceSlashCommands(): SlashCommand[];
@@ -22,6 +23,7 @@ export declare class AppMenuItemsController {
22
23
  getThinkingMenuItems(query: string): PopupMenuItem<ThinkingMenuValue>[];
23
24
  getUserMessageMenuItems(): PopupMenuItem<UserMessageMenuValue>[];
24
25
  getUserMessageJumpMenuItems(query: string): PopupMenuItem<UserMessageJumpMenuValue>[];
26
+ isUserMessageJumpLoading(): boolean;
25
27
  refreshUserMessageJumpMenuItems(): Promise<void>;
26
28
  getQueueMessageMenuItems(): PopupMenuItem<QueueMessageMenuValue>[];
27
29
  getResumeMenuItems(query: string, limit?: number): PopupMenuItem<ResumeMenuValue>[];
@@ -12,6 +12,7 @@ export class AppMenuItemsController {
12
12
  host;
13
13
  resumeMenuLoaderCache;
14
14
  userMessageJumpItems;
15
+ userMessageJumpLoading = false;
15
16
  constructor(host) {
16
17
  this.host = host;
17
18
  }
@@ -29,6 +30,9 @@ export class AppMenuItemsController {
29
30
  value: match.value,
30
31
  label: `/${match.value.name}`,
31
32
  description: match.value.description,
33
+ labelHighlightRanges: match.matchedText === match.label
34
+ ? match.matchedRanges.map((range) => ({ start: range.start + 1, end: range.end + 1 }))
35
+ : [],
32
36
  }));
33
37
  }
34
38
  modelRef(model) {
@@ -64,6 +68,7 @@ export class AppMenuItemsController {
64
68
  value: match.value,
65
69
  label: `${match.value.ref}${match.value.current ? ` ${APP_ICONS.check}` : ""}`,
66
70
  description: match.value.model.name,
71
+ labelHighlightRanges: labelHighlightRangesFromMatch(match.matchedText, match.matchedRanges, match.label),
67
72
  }));
68
73
  }
69
74
  getThinkingMenuItems(query) {
@@ -96,26 +101,36 @@ export class AppMenuItemsController {
96
101
  getUserMessageJumpMenuItems(query) {
97
102
  return filterUserMessageJumpItems(this.userMessageJumpItems ?? buildUserMessageJumpItems(this.host.getEntries()), query);
98
103
  }
104
+ isUserMessageJumpLoading() {
105
+ return this.userMessageJumpLoading;
106
+ }
99
107
  async refreshUserMessageJumpMenuItems() {
100
108
  const runtime = this.host.runtime();
101
109
  if (!runtime) {
102
110
  this.userMessageJumpItems = undefined;
111
+ this.userMessageJumpLoading = false;
103
112
  return;
104
113
  }
105
- const entries = await sessionHistoryFullBranchEntries(runtime.session);
106
- const loadedBySessionEntryId = new Map(this.host.getEntries()
107
- .filter((entry) => entry.kind === "user" && typeof entry.sessionEntryId === "string")
108
- .map((entry) => [entry.sessionEntryId, entry]));
109
- const sources = entries.flatMap((entry) => {
110
- if (entry.type !== "message" || !isRecord(entry.message) || entry.message.role !== "user")
111
- return [];
112
- const text = renderUserMessageContent(entry.message.content);
113
- if (!text)
114
- return [];
115
- const loaded = loadedBySessionEntryId.get(entry.id);
116
- return [{ text, ...(loaded ? { entryId: loaded.id } : {}), sessionEntryId: entry.id }];
117
- });
118
- this.userMessageJumpItems = buildUserMessageJumpItems(sources);
114
+ this.userMessageJumpLoading = true;
115
+ try {
116
+ const entries = await sessionHistoryFullBranchEntries(runtime.session);
117
+ const loadedBySessionEntryId = new Map(this.host.getEntries()
118
+ .filter((entry) => entry.kind === "user" && typeof entry.sessionEntryId === "string")
119
+ .map((entry) => [entry.sessionEntryId, entry]));
120
+ const sources = entries.flatMap((entry) => {
121
+ if (entry.type !== "message" || !isRecord(entry.message) || entry.message.role !== "user")
122
+ return [];
123
+ const text = renderUserMessageContent(entry.message.content);
124
+ if (!text)
125
+ return [];
126
+ const loaded = loadedBySessionEntryId.get(entry.id);
127
+ return [{ text, ...(loaded ? { entryId: loaded.id } : {}), sessionEntryId: entry.id }];
128
+ });
129
+ this.userMessageJumpItems = buildUserMessageJumpItems(sources);
130
+ }
131
+ finally {
132
+ this.userMessageJumpLoading = false;
133
+ }
119
134
  }
120
135
  getQueueMessageMenuItems() {
121
136
  return [
@@ -205,6 +220,14 @@ export class AppMenuItemsController {
205
220
  }
206
221
  }
207
222
  }
223
+ function labelHighlightRangesFromMatch(matchedText, matchedRanges, label) {
224
+ if (matchedText === label)
225
+ return matchedRanges;
226
+ const offset = label.toLocaleLowerCase().indexOf(matchedText.toLocaleLowerCase());
227
+ if (offset < 0)
228
+ return [];
229
+ return matchedRanges.map((range) => ({ start: offset + range.start, end: offset + range.end }));
230
+ }
208
231
  function normalizeAvailableThinkingLevels(levels) {
209
232
  const seen = new Set();
210
233
  const normalized = [];
@@ -666,10 +666,18 @@ export class AppPopupMenuController {
666
666
  value: item,
667
667
  label: item.label,
668
668
  ...(item.keywords === undefined ? {} : { keywords: item.keywords }),
669
- })), query).map((match) => match.value);
669
+ })), query, {
670
+ ...(request.options.minScorePerCharacter === undefined ? {} : { minScorePerCharacter: request.options.minScorePerCharacter }),
671
+ preferKeyboardLayoutMatches: request.options.preferKeyboardLayoutMatches ?? false,
672
+ }).map((match) => ({
673
+ ...match.value,
674
+ labelHighlightRanges: match.matchedText === match.label ? match.matchedRanges : [],
675
+ }));
670
676
  return this.withoutCloseMenuItems(items.map((item) => ({
671
677
  value: item,
672
678
  label: item.label,
679
+ ...(item.labelHighlightRanges === undefined ? {} : { labelHighlightRanges: item.labelHighlightRanges }),
680
+ ...(item.descriptionHighlightRanges === undefined ? {} : { descriptionHighlightRanges: item.descriptionHighlightRanges }),
673
681
  ...(item.description === undefined ? {} : { description: item.description }),
674
682
  })));
675
683
  }
@@ -733,7 +741,8 @@ function formatSessionMenuDateTime(dateTime) {
733
741
  time: dateTime.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", hourCycle: "h23" }),
734
742
  };
735
743
  }
736
- function formatSessionInfoMenuItem(session, labelPrefix = "") {
744
+ function formatSessionInfoMenuItem(source) {
745
+ const { session, labelPrefix } = source;
737
746
  const { date, time } = formatSessionMenuDateTime(session.modified);
738
747
  const messages = `${session.messageCount} msg${session.messageCount !== 1 ? "s" : ""}`;
739
748
  const label = session.name ?? session.firstMessage.slice(0, 50);
@@ -741,6 +750,7 @@ function formatSessionInfoMenuItem(session, labelPrefix = "") {
741
750
  value: session,
742
751
  label: `${labelPrefix}${label}`,
743
752
  description: `${date} ${time} · ${messages} · ${session.id.slice(0, 8)}`,
753
+ ...(source.labelHighlightRanges === undefined ? {} : { labelHighlightRanges: source.labelHighlightRanges }),
744
754
  };
745
755
  }
746
756
  function buildSessionInfoMenuSource(sessions, currentSessionFile, query) {
@@ -761,7 +771,11 @@ function buildSessionInfoMenuSource(sessions, currentSessionFile, query) {
761
771
  session.id,
762
772
  ],
763
773
  }));
764
- return fuzzySearch(items, query).map((match) => ({ session: match.value, labelPrefix: "" }));
774
+ return fuzzySearch(items, query).map((match) => ({
775
+ session: match.value,
776
+ labelPrefix: "",
777
+ labelHighlightRanges: match.matchedText === match.label ? match.matchedRanges : [],
778
+ }));
765
779
  }
766
780
  export function createSessionInfoMenuItemsLoader(sessions, currentSessionFile, query) {
767
781
  const source = buildSessionInfoMenuSource(sessions, currentSessionFile, query);
@@ -775,7 +789,7 @@ export function createSessionInfoMenuItemsLoader(sessions, currentSessionFile, q
775
789
  const cached = cachedItems.get(effectiveLimit);
776
790
  if (cached)
777
791
  return cached;
778
- const result = source.slice(0, effectiveLimit).map((item) => formatSessionInfoMenuItem(item.session, item.labelPrefix));
792
+ const result = source.slice(0, effectiveLimit).map((item) => formatSessionInfoMenuItem(item));
779
793
  cachedItems.set(effectiveLimit, result);
780
794
  return result;
781
795
  },
@@ -812,5 +826,16 @@ export function filterUserMessageJumpItems(items, query) {
812
826
  ...(item.aliases === undefined ? {} : { aliases: item.aliases }),
813
827
  ...(item.keywords === undefined ? {} : { keywords: item.keywords }),
814
828
  }));
815
- return fuzzySearch(searchableItems, query).map((match) => match.value);
829
+ return fuzzySearch(searchableItems, query).map((match) => ({
830
+ ...match.value,
831
+ labelHighlightRanges: labelHighlightRangesFromMatch(match.matchedText, match.matchedRanges, match.label),
832
+ }));
833
+ }
834
+ function labelHighlightRangesFromMatch(matchedText, matchedRanges, label) {
835
+ if (matchedText === label)
836
+ return matchedRanges;
837
+ const offset = label.toLocaleLowerCase().indexOf(matchedText.toLocaleLowerCase());
838
+ if (offset < 0)
839
+ return [];
840
+ return matchedRanges.map((range) => ({ start: offset + range.start, end: offset + range.end }));
816
841
  }
@@ -20,35 +20,46 @@ export function renderTodoPanel(details, expanded, width, colors) {
20
20
  const todoPanelColor = colors.warning;
21
21
  const todoMetaColor = colors.muted;
22
22
  const todoThinkingColor = (level) => thinkingLevelThemeColor(level, colors);
23
+ const todoStatusThemeColor = (status) => {
24
+ switch (status) {
25
+ case "pending": return colors.muted;
26
+ case "in_progress": return colors.warning;
27
+ case "deferred": return colors.muted;
28
+ case "completed": return colors.success;
29
+ case "deleted": return colors.error;
30
+ }
31
+ };
23
32
  if (!expanded) {
24
33
  const prefix = `${headerText} — current: `;
25
34
  const current = activeTask ? formatTodoTaskLine(activeTask) : "no active todo";
26
35
  const collapsedText = `${prefix}${current}`;
27
- const segments = activeTask
28
- ? todoTaskLineSegments(activeTask, todoMetaColor, { thinkingColor: todoThinkingColor }).map((segment) => ({
36
+ const segments = [
37
+ { start: 0, end: headerText.length, foreground: todoPanelColor },
38
+ { start: headerText.length, end: prefix.length, foreground: todoMetaColor },
39
+ ];
40
+ if (activeTask) {
41
+ const activeSegments = todoTaskLineSegments(activeTask, todoMetaColor, { thinkingColor: todoThinkingColor, statusColor: todoStatusThemeColor }).map((segment) => ({
29
42
  ...segment,
30
43
  start: segment.start + prefix.length,
31
44
  end: segment.end + prefix.length,
32
- }))
33
- : undefined;
45
+ }));
46
+ segments.push(...activeSegments);
47
+ }
34
48
  const line = {
35
49
  text: padOrTrimPlain(ellipsizeDisplay(collapsedText, contentWidth), width),
36
- colorOverride: todoPanelColor,
50
+ segments,
37
51
  target,
38
52
  };
39
- if (segments)
40
- line.segments = segments;
41
53
  return [line];
42
54
  }
43
55
  const lines = [];
44
56
  for (const { task, depth } of visibleTodoTaskRows(details)) {
45
57
  const text = formatTodoTaskLine(task, { depth });
46
- const segments = todoTaskLineSegments(task, todoMetaColor, { depth, thinkingColor: todoThinkingColor });
58
+ const segments = todoTaskLineSegments(task, todoMetaColor, { depth, thinkingColor: todoThinkingColor, statusColor: todoStatusThemeColor });
47
59
  let start = 0;
48
60
  for (const wrapped of wrapLine(text, contentWidth)) {
49
61
  lines.push({
50
62
  text: padOrTrimPlain(wrapped, width),
51
- colorOverride: todoPanelColor,
52
63
  segments: shiftSegmentsToSlice(segments, start, wrapped.length),
53
64
  target,
54
65
  });
@@ -1,4 +1,5 @@
1
1
  import { type Theme } from "../../theme.js";
2
+ import { type ModelColorsConfig } from "../../config.js";
2
3
  import type { PopupMenu } from "../../ui.js";
3
4
  import type { ScreenStyler } from "../screen/screen-styler.js";
4
5
  import type { Entry, ModelMenuValue, PixMenuItem, PixMenuOptions, QueueMessageMenuValue, RenderedLine, ResumeMenuValue, SlashCommand, ThinkingMenuValue, UserMessageJumpMenuValue, UserMessageMenuValue } from "../types.js";
@@ -8,8 +9,10 @@ export type PopupMenuRendererHost = {
8
9
  readonly screenStyler: ScreenStyler;
9
10
  readonly entries: readonly Entry[];
10
11
  readonly session: AgentSession | undefined;
12
+ readonly modelColors?: ModelColorsConfig;
11
13
  readonly resumeLoading: boolean;
12
14
  readonly resumeSessionCount: number;
15
+ readonly userMessageJumpLoading: boolean;
13
16
  };
14
17
  export declare class PopupMenuRenderer {
15
18
  private readonly host;
@@ -38,10 +41,19 @@ export declare class PopupMenuRenderer {
38
41
  options: PixMenuOptions;
39
42
  } | undefined, directQuery: string): RenderedLine[];
40
43
  private hasPopupActionItems;
44
+ private labelDescriptionText;
41
45
  private userMessageActionForeground;
42
46
  private selectableItemVariant;
47
+ private thinkingMenuItemSegments;
48
+ private modelMenuItemSegments;
49
+ private modelMenuItemColor;
50
+ private availableThinkingLevels;
43
51
  private queueMessageItemVariant;
44
52
  private sdkItemVariant;
53
+ private sdkMenuItemSegments;
54
+ private itemHighlightSegments;
55
+ private highlightSegments;
56
+ private descriptionHighlightSegments;
45
57
  private resumeMenuItemSegments;
46
58
  private popupMenuHeader;
47
59
  private popupLineForeground;
@@ -1,9 +1,13 @@
1
1
  import { colorLine } from "../../theme.js";
2
2
  import { stringDisplayWidth } from "../../terminal-width.js";
3
+ import { resolveColor, resolveModelColor } from "../../config.js";
3
4
  import { SLASH_COMMAND_DESCRIPTION_COLUMN, } from "../constants.js";
4
5
  import { APP_ICONS } from "../icons.js";
5
6
  import { ellipsizeDisplay, padOrTrimPlain, sanitizeText } from "./render-text.js";
7
+ import { modelProviderThemeColor, thinkingLevelThemeColor } from "./status-line-renderer.js";
6
8
  const POPUP_MENU_ESCAPE_BUTTON = "Esc";
9
+ const POPUP_MENU_DESCRIPTION_GAP = " ";
10
+ const POPUP_MENU_HEADER_SIDE_PADDING = 2;
7
11
  export class PopupMenuRenderer {
8
12
  host;
9
13
  constructor(host) {
@@ -25,7 +29,7 @@ export class PopupMenuRenderer {
25
29
  const menuWidth = this.effectivePopupMenuWidth(width);
26
30
  const rightMargin = Math.max(0, width - margin - menuWidth);
27
31
  const selected = line.target?.kind === "popup-menu" && activeMenu.selectedIndex === line.target.index;
28
- const foreground = this.popupLineForeground(line, selected);
32
+ const foreground = this.popupLineForeground(line);
29
33
  const background = this.popupLineBackground(line, selected);
30
34
  const plain = `${" ".repeat(margin)}${padOrTrimPlain(line.text, menuWidth)}${" ".repeat(rightMargin)}`;
31
35
  if (this.host.screenStyler.selectionRangeForRow(row, width)) {
@@ -59,7 +63,7 @@ export class PopupMenuRenderer {
59
63
  for (const item of menu.visibleItems()) {
60
64
  const label = item.label.padEnd(18, " ");
61
65
  const description = item.description ?? "";
62
- const marker = item.selected ? "" : " ";
66
+ const marker = item.selected ? "" : " ";
63
67
  const rawText = `${marker} ${label}${description}`;
64
68
  const text = ellipsizeDisplay(rawText, options.userContentWidth);
65
69
  const line = options.userLine(text);
@@ -73,7 +77,7 @@ export class PopupMenuRenderer {
73
77
  {
74
78
  start: labelStart,
75
79
  end: labelEnd,
76
- foreground: this.userMessageActionForeground(item.selected, item.value),
80
+ foreground: this.userMessageActionForeground(item.value),
77
81
  bold: item.selected,
78
82
  },
79
83
  ...(descriptionStart < contentStart + text.length
@@ -91,11 +95,12 @@ export class PopupMenuRenderer {
91
95
  lines.push({ text: " No matching slash commands", variant: "muted" });
92
96
  }
93
97
  for (const item of visibleItems) {
94
- const command = item.label.padEnd(SLASH_COMMAND_DESCRIPTION_COLUMN, " ");
95
- const description = item.description ?? "";
98
+ const marker = item.selected ? " " : " ";
99
+ const text = `${marker}${this.labelDescriptionText(item.label, item.description, width - 2)}`;
96
100
  lines.push({
97
- text: `${command}${description}`,
98
- variant: item.selected ? "accent" : "normal",
101
+ text,
102
+ variant: "normal",
103
+ segments: this.itemHighlightSegments(item, text),
99
104
  target: { kind: "popup-menu", index: item.index },
100
105
  });
101
106
  }
@@ -111,11 +116,12 @@ export class PopupMenuRenderer {
111
116
  });
112
117
  }
113
118
  for (const item of visibleItems) {
114
- const model = item.label.padEnd(SLASH_COMMAND_DESCRIPTION_COLUMN, " ");
115
- const description = item.description ?? "";
119
+ const marker = item.selected ? " " : " ";
120
+ const text = `${marker}${this.labelDescriptionText(item.label, item.description, width - 2)}`;
116
121
  lines.push({
117
- text: `${model}${description}`,
118
- variant: this.selectableItemVariant(item.selected, item.value),
122
+ text,
123
+ variant: this.selectableItemVariant(item.value),
124
+ segments: [...this.modelMenuItemSegments(item.value), ...this.itemHighlightSegments(item, text)],
119
125
  target: { kind: "popup-menu", index: item.index },
120
126
  });
121
127
  }
@@ -128,11 +134,12 @@ export class PopupMenuRenderer {
128
134
  lines.push({ text: " No matching thinking levels", variant: "muted" });
129
135
  }
130
136
  for (const item of visibleItems) {
131
- const level = item.label.padEnd(SLASH_COMMAND_DESCRIPTION_COLUMN, " ");
132
- const description = item.description ?? "";
137
+ const marker = item.selected ? " " : " ";
138
+ const text = `${marker}${this.labelDescriptionText(item.label, item.description, width - 2)}`;
133
139
  lines.push({
134
- text: `${level}${description}`,
135
- variant: this.selectableItemVariant(item.selected, item.value),
140
+ text,
141
+ variant: this.selectableItemVariant(item.value),
142
+ segments: this.thinkingMenuItemSegments(item.value),
136
143
  target: { kind: "popup-menu", index: item.index },
137
144
  });
138
145
  }
@@ -151,12 +158,13 @@ export class PopupMenuRenderer {
151
158
  for (const item of visibleItems) {
152
159
  const label = item.label;
153
160
  const description = item.description ?? "";
154
- const text = `${label} ${description}`;
155
- const segments = this.resumeMenuItemSegments(item.value, label, description, text);
161
+ const marker = item.selected ? "▶ " : " ";
162
+ const text = `${marker}${label} ${description}`;
163
+ const segments = [...(this.resumeMenuItemSegments(item.value, label, description, text) ?? []), ...this.itemHighlightSegments(item, text)];
156
164
  lines.push({
157
165
  text,
158
- variant: item.selected ? "accent" : "normal",
159
- ...(segments ? { segments } : {}),
166
+ variant: "normal",
167
+ ...(segments.length === 0 ? {} : { segments }),
160
168
  target: { kind: "popup-menu", index: item.index },
161
169
  });
162
170
  }
@@ -170,18 +178,24 @@ export class PopupMenuRenderer {
170
178
  }
171
179
  renderUserMessageJumpMenu(width, menu, directQuery) {
172
180
  const lines = [this.popupMenuHeader("Jump to user message", width)];
173
- if (!this.hasPopupActionItems(menu.items)) {
181
+ if (this.host.userMessageJumpLoading) {
182
+ lines.push({ text: ` ${APP_ICONS.timerSand} Loading user messages`, variant: "muted" });
183
+ }
184
+ else if (!this.hasPopupActionItems(menu.items)) {
174
185
  lines.push({
175
186
  text: this.host.entries.some((entry) => entry.kind === "user") ? " No matching user messages" : " No user messages yet",
176
187
  variant: "muted",
177
188
  });
178
189
  }
179
- const labelWidth = Math.max(1, width);
190
+ const labelWidth = Math.max(1, width - 2);
180
191
  for (const item of menu.visibleItems()) {
181
192
  const label = ellipsizeDisplay(item.label, labelWidth);
193
+ const marker = item.selected ? "▶ " : " ";
194
+ const text = `${marker}${label}`;
182
195
  lines.push({
183
- text: label,
184
- variant: item.selected ? "accent" : "normal",
196
+ text,
197
+ variant: "normal",
198
+ segments: this.itemHighlightSegments(item, text),
185
199
  target: { kind: "popup-menu", index: item.index },
186
200
  });
187
201
  }
@@ -193,11 +207,10 @@ export class PopupMenuRenderer {
193
207
  renderQueueMessageMenu(width, menu) {
194
208
  const lines = [this.popupMenuHeader("Queued message", width)];
195
209
  for (const item of menu.visibleItems()) {
196
- const label = item.label.padEnd(18, " ");
197
- const description = item.description ?? "";
210
+ const marker = item.selected ? " " : " ";
198
211
  lines.push({
199
- text: `${label}${description}`,
200
- variant: this.queueMessageItemVariant(item.selected, item.value),
212
+ text: `${marker}${this.labelDescriptionText(item.label, item.description, width - 2, 16)}`,
213
+ variant: this.queueMessageItemVariant(item.value),
201
214
  target: { kind: "popup-menu", index: item.index },
202
215
  });
203
216
  }
@@ -209,11 +222,13 @@ export class PopupMenuRenderer {
209
222
  lines.push({ text: ` ${request?.options.emptyText ?? "No matching items"}`, variant: "muted" });
210
223
  }
211
224
  for (const item of menu.visibleItems()) {
212
- const label = item.label.padEnd(SLASH_COMMAND_DESCRIPTION_COLUMN, " ");
213
- const description = item.description ?? "";
225
+ const marker = item.selected ? " " : " ";
226
+ const text = `${marker}${this.labelDescriptionText(item.label, item.description, width - 2)}`;
227
+ const segments = this.sdkMenuItemSegments(item, text);
214
228
  lines.push({
215
- text: `${label}${description}`,
216
- variant: this.sdkItemVariant(item.selected, item.value),
229
+ text,
230
+ variant: this.sdkItemVariant(item.value),
231
+ ...(segments.length === 0 ? {} : { segments }),
217
232
  target: { kind: "popup-menu", index: item.index },
218
233
  });
219
234
  }
@@ -225,39 +240,117 @@ export class PopupMenuRenderer {
225
240
  hasPopupActionItems(items) {
226
241
  return items.length > 0;
227
242
  }
228
- userMessageActionForeground(selected, value) {
229
- if (selected)
230
- return this.host.theme.colors.accent;
243
+ labelDescriptionText(label, description, width, labelColumn = SLASH_COMMAND_DESCRIPTION_COLUMN) {
244
+ const safeLabel = sanitizeText(label).replace(/\s+/gu, " ");
245
+ const safeDescription = description ? sanitizeText(description).replace(/\s+/gu, " ") : "";
246
+ if (!safeDescription)
247
+ return ellipsizeDisplay(safeLabel, width);
248
+ const gapWidth = stringDisplayWidth(POPUP_MENU_DESCRIPTION_GAP);
249
+ const labelDisplayWidth = stringDisplayWidth(safeLabel);
250
+ const descriptionDisplayWidth = stringDisplayWidth(safeDescription);
251
+ if (width <= gapWidth + 1)
252
+ return ellipsizeDisplay(safeLabel, width);
253
+ if (labelDisplayWidth <= labelColumn && labelColumn + gapWidth + descriptionDisplayWidth <= width) {
254
+ return `${safeLabel}${" ".repeat(labelColumn - labelDisplayWidth)}${POPUP_MENU_DESCRIPTION_GAP}${safeDescription}`;
255
+ }
256
+ if (labelDisplayWidth + gapWidth + descriptionDisplayWidth <= width) {
257
+ return `${safeLabel}${POPUP_MENU_DESCRIPTION_GAP}${safeDescription}`;
258
+ }
259
+ const labelWidth = descriptionDisplayWidth < width - gapWidth - 1
260
+ ? Math.max(1, width - gapWidth - descriptionDisplayWidth)
261
+ : Math.max(1, Math.min(labelColumn, width - gapWidth - 1));
262
+ const visibleLabel = ellipsizeDisplay(safeLabel, labelWidth);
263
+ const padding = " ".repeat(Math.max(0, labelWidth - stringDisplayWidth(visibleLabel)));
264
+ return `${visibleLabel}${padding}${POPUP_MENU_DESCRIPTION_GAP}${safeDescription}`;
265
+ }
266
+ userMessageActionForeground(value) {
231
267
  if (value === "undo")
232
268
  return this.host.theme.colors.error;
233
269
  return this.host.theme.colors.inputForeground;
234
270
  }
235
- selectableItemVariant(selected, value) {
236
- if (selected)
237
- return "accent";
271
+ selectableItemVariant(value) {
238
272
  return value.current ? "muted" : "normal";
239
273
  }
240
- queueMessageItemVariant(selected, value) {
241
- if (selected)
242
- return "accent";
274
+ thinkingMenuItemSegments(value) {
275
+ const markerOffset = 2; // "▶ " or " "
276
+ return [{
277
+ start: markerOffset,
278
+ end: markerOffset + value.level.length,
279
+ foreground: thinkingLevelThemeColor(value.level, this.host.theme.colors, this.availableThinkingLevels()),
280
+ }];
281
+ }
282
+ modelMenuItemSegments(value) {
283
+ const markerOffset = 2; // "▶ " or " "
284
+ return [{
285
+ start: markerOffset,
286
+ end: markerOffset + value.ref.length,
287
+ foreground: this.modelMenuItemColor(value),
288
+ }];
289
+ }
290
+ modelMenuItemColor(value) {
291
+ const configuredColor = this.host.modelColors
292
+ ? resolveModelColor(value.ref, this.host.modelColors)
293
+ : undefined;
294
+ return configuredColor
295
+ ? resolveColor(configuredColor, this.host.theme.colors)
296
+ : modelProviderThemeColor(value.model.provider, this.host.theme.colors);
297
+ }
298
+ availableThinkingLevels() {
299
+ const levels = this.host.session?.getAvailableThinkingLevels();
300
+ return Array.isArray(levels) && levels.length > 0 ? levels.map(String) : ["off", "minimal", "low", "medium", "high", "xhigh"];
301
+ }
302
+ queueMessageItemVariant(value) {
243
303
  return value === "cancel" ? "error" : "normal";
244
304
  }
245
- sdkItemVariant(selected, value) {
246
- if (selected)
247
- return "accent";
305
+ sdkItemVariant(value) {
248
306
  return value.variant ?? "normal";
249
307
  }
308
+ sdkMenuItemSegments(item, text) {
309
+ return [
310
+ ...this.highlightSegments(item.labelHighlightRanges ?? item.value.labelHighlightRanges ?? [], text, 2),
311
+ ...this.descriptionHighlightSegments(item.description, item.descriptionHighlightRanges ?? item.value.descriptionHighlightRanges ?? [], text),
312
+ ];
313
+ }
314
+ itemHighlightSegments(item, text) {
315
+ return this.highlightSegments(item.labelHighlightRanges ?? [], text, 2);
316
+ }
317
+ highlightSegments(ranges, text, markerOffset) {
318
+ if (ranges.length === 0)
319
+ return [];
320
+ return ranges.flatMap((range) => {
321
+ const start = Math.max(markerOffset, markerOffset + range.start);
322
+ const end = Math.min(text.length, markerOffset + range.end);
323
+ if (end <= start)
324
+ return [];
325
+ return [{
326
+ start,
327
+ end,
328
+ foreground: this.host.theme.colors.accent,
329
+ bold: true,
330
+ }];
331
+ });
332
+ }
333
+ descriptionHighlightSegments(description, ranges, text) {
334
+ if (!description || ranges.length === 0)
335
+ return [];
336
+ const safeDescription = sanitizeText(description).replace(/\s+/gu, " ");
337
+ const descriptionStart = text.indexOf(safeDescription, 2);
338
+ if (descriptionStart < 0)
339
+ return [];
340
+ return this.highlightSegments(ranges, text, descriptionStart);
341
+ }
250
342
  resumeMenuItemSegments(value, label, description, text) {
251
343
  if (value.kind !== "session")
252
344
  return undefined;
253
345
  const sessionLabel = value.session.name ?? value.session.firstMessage.slice(0, 50);
254
- const sessionLabelStart = Math.max(0, label.length - sessionLabel.length);
346
+ const markerOffset = 2; // "▶ " or " "
347
+ const sessionLabelStart = Math.max(0, label.length - sessionLabel.length) + markerOffset;
255
348
  const muted = this.host.theme.colors.popupMuted;
256
349
  const segments = [];
257
- if (sessionLabelStart > 0)
258
- segments.push({ start: 0, end: sessionLabelStart, foreground: muted });
350
+ if (sessionLabelStart > markerOffset)
351
+ segments.push({ start: markerOffset, end: sessionLabelStart, foreground: muted });
259
352
  if (description.length > 0)
260
- segments.push({ start: label.length, end: text.length, foreground: muted });
353
+ segments.push({ start: markerOffset + label.length, end: text.length, foreground: muted });
261
354
  return segments.length > 0 ? segments : undefined;
262
355
  }
263
356
  popupMenuHeader(title, width) {
@@ -268,10 +361,8 @@ export class PopupMenuRenderer {
268
361
  target: { kind: "popup-menu-close" },
269
362
  };
270
363
  }
271
- popupLineForeground(line, selected) {
364
+ popupLineForeground(line) {
272
365
  const colors = this.host.theme.colors;
273
- if (selected)
274
- return colors.popupSelectedForeground;
275
366
  if (line.colorOverride)
276
367
  return line.colorOverride;
277
368
  switch (line.variant) {
@@ -300,8 +391,15 @@ export function formatPopupMenuHeader(title, width) {
300
391
  const buttonWidth = stringDisplayWidth(POPUP_MENU_ESCAPE_BUTTON);
301
392
  if (safeWidth <= buttonWidth + 1)
302
393
  return padOrTrimPlain(POPUP_MENU_ESCAPE_BUTTON, safeWidth);
303
- const titleWidth = safeWidth - buttonWidth - 1;
394
+ const sidePadding = safeWidth >= buttonWidth + POPUP_MENU_HEADER_SIDE_PADDING * 2 + 2
395
+ ? POPUP_MENU_HEADER_SIDE_PADDING
396
+ : 1;
397
+ const contentWidth = Math.max(1, safeWidth - sidePadding * 2);
398
+ if (contentWidth <= buttonWidth + 1) {
399
+ return padOrTrimPlain(`${" ".repeat(sidePadding)}${POPUP_MENU_ESCAPE_BUTTON}`, safeWidth);
400
+ }
401
+ const titleWidth = contentWidth - buttonWidth - 1;
304
402
  const titleText = ellipsizeDisplay(sanitizedTitle, titleWidth);
305
- const gapWidth = Math.max(1, safeWidth - stringDisplayWidth(titleText) - buttonWidth);
306
- return `${titleText}${" ".repeat(gapWidth)}${POPUP_MENU_ESCAPE_BUTTON}`;
403
+ const gapWidth = Math.max(1, contentWidth - stringDisplayWidth(titleText) - buttonWidth);
404
+ return `${" ".repeat(sidePadding)}${titleText}${" ".repeat(gapWidth)}${POPUP_MENU_ESCAPE_BUTTON}${" ".repeat(sidePadding)}`;
307
405
  }