pi-ui-extend 0.1.78 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/app/app.js CHANGED
@@ -387,6 +387,7 @@ export class PiUiExtendApp {
387
387
  this.sessionEvents = new AppSessionEventController({
388
388
  entries: this.entries,
389
389
  runtime: () => this.runtime,
390
+ awaitSessionExtensions: (runtime) => this.awaitCurrentSessionExtensions(runtime),
390
391
  conversationViewport: () => this.conversationViewport,
391
392
  conversationViewportColumns: () => this.terminalColumns(),
392
393
  onHistoryWindowPruned: (edge, lineCount) => this.scrollController.adjustForHistoryWindowPrune(edge, lineCount),
@@ -421,6 +422,7 @@ export class PiUiExtendApp {
421
422
  this.queuedMessages = new AppQueuedMessageController({
422
423
  runtime: () => this.runtime,
423
424
  requireRuntime: () => this.requireRuntime(),
425
+ awaitCurrentSessionExtensions: (runtime) => this.awaitCurrentSessionExtensions(runtime),
424
426
  visibleEntries: () => this.conversationViewport.entries(),
425
427
  isRunning: () => this.running,
426
428
  render: () => this.render(),
@@ -605,6 +607,7 @@ export class PiUiExtendApp {
605
607
  toastEntry: (toastId) => this.toastController.entry(toastId),
606
608
  showToast: (message, kind, options) => this.showToast(message, kind, options),
607
609
  dismissToast: (toastId) => this.toastController.dismissToast(toastId),
610
+ activateToastAction: (toastId) => this.toastController.activateAction(toastId),
608
611
  refreshModelUsageStatus: () => this.refreshModelUsageStatusFromClick(),
609
612
  refreshUserMessageJumpMenuItems: () => this.menuItems.refreshUserMessageJumpMenuItems(),
610
613
  queueInputFromStatus: () => {
@@ -1,4 +1,19 @@
1
1
  import { type ToastEntry, type ToastKind, type ToastVariant } from "../../ui.js";
2
+ export type AppToastEntry = ToastEntry & {
3
+ action?: {
4
+ label: string;
5
+ };
6
+ };
7
+ export type AppToastAction = {
8
+ label: string;
9
+ onSelect: () => void;
10
+ };
11
+ export type AppToastOptions = {
12
+ durationMs?: number;
13
+ variant?: ToastVariant;
14
+ scopeKey?: string;
15
+ action?: AppToastAction;
16
+ };
2
17
  export type AppToastControllerHost = {
3
18
  activeScope?(): string | undefined;
4
19
  render(): void;
@@ -7,19 +22,19 @@ export declare class AppToastController {
7
22
  private readonly host;
8
23
  private readonly toastsByScope;
9
24
  private readonly timers;
25
+ private readonly actions;
10
26
  constructor(host: AppToastControllerHost);
11
- showToast(message: string, kind?: ToastKind, options?: {
12
- durationMs?: number;
13
- variant?: ToastVariant;
14
- scopeKey?: string;
15
- }): void;
27
+ showToast(message: string, kind?: ToastKind, options?: AppToastOptions): void;
16
28
  dismissToast(toastId: number, scopeKey?: string): void;
29
+ activateAction(toastId: number, scopeKey?: string): boolean;
17
30
  dismissActiveDialog(scopeKey?: string): boolean;
18
- visibleStates(scopeKey?: string): readonly ToastEntry[];
31
+ visibleStates(scopeKey?: string): readonly AppToastEntry[];
19
32
  entry(toastId: number, scopeKey?: string): ToastEntry | undefined;
20
33
  clearToastTimers(): void;
21
34
  private toastForScope;
22
35
  private timersForScope;
36
+ private actionsForScope;
37
+ private removeAction;
23
38
  private deleteScopeIfEmpty;
24
39
  private normalizeScopeKey;
25
40
  }
@@ -4,13 +4,17 @@ export class AppToastController {
4
4
  host;
5
5
  toastsByScope = new Map();
6
6
  timers = new Map();
7
+ actions = new Map();
7
8
  constructor(host) {
8
9
  this.host = host;
9
10
  }
10
11
  showToast(message, kind = "info", options = {}) {
11
12
  const scopeKey = this.normalizeScopeKey(options.scopeKey ?? this.host.activeScope?.());
12
13
  const toast = this.toastForScope(scopeKey);
14
+ const action = options.variant === "dialog" ? undefined : options.action;
13
15
  const toastId = toast.show(message, kind, options.variant ? { variant: options.variant } : {});
16
+ if (action)
17
+ this.actionsForScope(scopeKey).set(toastId, action);
14
18
  if (kind === "error" || options.variant === "dialog") {
15
19
  this.host.render();
16
20
  return;
@@ -21,6 +25,7 @@ export class AppToastController {
21
25
  const timer = setTimeout(() => {
22
26
  toast.hide(toastId);
23
27
  this.timers.get(scopeKey)?.delete(toastId);
28
+ this.removeAction(scopeKey, toastId);
24
29
  this.deleteScopeIfEmpty(scopeKey);
25
30
  this.host.render();
26
31
  }, durationMs);
@@ -36,9 +41,18 @@ export class AppToastController {
36
41
  timers?.delete(toastId);
37
42
  }
38
43
  this.toastsByScope.get(scopeKey)?.hide(toastId);
44
+ this.removeAction(scopeKey, toastId);
39
45
  this.deleteScopeIfEmpty(scopeKey);
40
46
  this.host.render();
41
47
  }
48
+ activateAction(toastId, scopeKey = this.normalizeScopeKey(this.host.activeScope?.())) {
49
+ const action = this.actions.get(scopeKey)?.get(toastId);
50
+ if (!action)
51
+ return false;
52
+ this.dismissToast(toastId, scopeKey);
53
+ action.onSelect();
54
+ return true;
55
+ }
42
56
  dismissActiveDialog(scopeKey = this.normalizeScopeKey(this.host.activeScope?.())) {
43
57
  const states = this.visibleStates(scopeKey);
44
58
  let dialog;
@@ -57,7 +71,14 @@ export class AppToastController {
57
71
  return true;
58
72
  }
59
73
  visibleStates(scopeKey = this.normalizeScopeKey(this.host.activeScope?.())) {
60
- return this.toastsByScope.get(scopeKey)?.visibleStates ?? [];
74
+ const states = this.toastsByScope.get(scopeKey)?.visibleStates ?? [];
75
+ const actions = this.actions.get(scopeKey);
76
+ if (!actions || actions.size === 0)
77
+ return states;
78
+ return states.map((state) => {
79
+ const action = actions.get(state.id);
80
+ return action ? { ...state, action: { label: action.label } } : state;
81
+ });
61
82
  }
62
83
  entry(toastId, scopeKey = this.normalizeScopeKey(this.host.activeScope?.())) {
63
84
  return this.toastsByScope.get(scopeKey)?.entry(toastId);
@@ -68,6 +89,7 @@ export class AppToastController {
68
89
  clearTimeout(timer);
69
90
  }
70
91
  this.timers.clear();
92
+ this.actions.clear();
71
93
  for (const toast of this.toastsByScope.values())
72
94
  toast.hide();
73
95
  this.toastsByScope.clear();
@@ -88,6 +110,20 @@ export class AppToastController {
88
110
  }
89
111
  return timers;
90
112
  }
113
+ actionsForScope(scopeKey) {
114
+ let actions = this.actions.get(scopeKey);
115
+ if (!actions) {
116
+ actions = new Map();
117
+ this.actions.set(scopeKey, actions);
118
+ }
119
+ return actions;
120
+ }
121
+ removeAction(scopeKey, toastId) {
122
+ const actions = this.actions.get(scopeKey);
123
+ actions?.delete(toastId);
124
+ if (actions?.size === 0)
125
+ this.actions.delete(scopeKey);
126
+ }
91
127
  deleteScopeIfEmpty(scopeKey) {
92
128
  const timers = this.timers.get(scopeKey);
93
129
  if (timers && timers.size === 0)
@@ -1,6 +1,6 @@
1
1
  import { type Theme } from "../../theme.js";
2
- import type { ToastEntry } from "../../ui.js";
3
2
  import type { ToastLineTarget } from "../types.js";
3
+ import type { AppToastEntry } from "./toast-controller.js";
4
4
  export type ToastOverlay = {
5
5
  id: number;
6
6
  row: number;
@@ -9,4 +9,4 @@ export type ToastOverlay = {
9
9
  output: string;
10
10
  target?: ToastLineTarget;
11
11
  };
12
- export declare function renderToastOverlays(states: readonly ToastEntry[], width: number, maxRows: number, theme: Theme): ToastOverlay[];
12
+ export declare function renderToastOverlays(states: readonly AppToastEntry[], width: number, maxRows: number, theme: Theme): ToastOverlay[];
@@ -15,10 +15,13 @@ export function renderToastOverlays(states, width, maxRows, theme) {
15
15
  }
16
16
  const icon = toastKindIcon(state.kind);
17
17
  const lines = toastMessageLines(state.message, icon, Math.max(1, width - 6));
18
- const visibleLines = lines.slice(0, Math.max(0, maxRows - overlays.length));
18
+ const availableRows = Math.max(0, maxRows - overlays.length);
19
+ const actionLabel = compactToastActionLabel(state);
20
+ const includeAction = actionLabel !== undefined && availableRows >= 2;
21
+ const visibleLines = lines.slice(0, Math.max(0, availableRows - (includeAction ? 1 : 0)));
19
22
  if (visibleLines.length === 0)
20
23
  continue;
21
- const contentWidth = Math.max(...visibleLines.map((line) => stringDisplayWidth(line)));
24
+ const contentWidth = Math.max(...visibleLines.map((line) => stringDisplayWidth(line)), ...(includeAction ? [stringDisplayWidth(actionLabel)] : []));
22
25
  const toastWidth = Math.min(Math.max(12, contentWidth + 2), Math.max(1, width - 4));
23
26
  const leftWidth = Math.max(0, width - toastWidth - 2);
24
27
  const column = leftWidth + 1;
@@ -39,9 +42,39 @@ export function renderToastOverlays(states, width, maxRows, theme) {
39
42
  target: { kind: "toast", id: state.id, action: "toast", startColumn: column, endColumn: column + toastWidth },
40
43
  });
41
44
  }
45
+ if (includeAction) {
46
+ const innerWidth = Math.max(0, toastWidth - 2);
47
+ const renderedLabel = padOrTrimPlain(actionLabel, innerWidth).trimEnd();
48
+ const labelWidth = stringDisplayWidth(renderedLabel);
49
+ if (labelWidth > 0) {
50
+ const leftPadding = Math.max(0, innerWidth - labelWidth);
51
+ const message = ` ${" ".repeat(leftPadding)}${renderedLabel} `;
52
+ const startColumn = column + 1 + leftPadding;
53
+ overlays.push({
54
+ id: state.id,
55
+ row: overlays.length + 1,
56
+ column,
57
+ text: padOrTrimPlain(message, toastWidth),
58
+ output: colorToastLine(message, toastWidth, { ...style, bold: true }),
59
+ target: {
60
+ kind: "toast",
61
+ id: state.id,
62
+ action: "action",
63
+ startColumn,
64
+ endColumn: startColumn + labelWidth,
65
+ },
66
+ });
67
+ }
68
+ }
42
69
  }
43
70
  return overlays;
44
71
  }
72
+ function compactToastActionLabel(state) {
73
+ if (!state.action)
74
+ return undefined;
75
+ const label = stripToastAnsi(sanitizeToastText(state.action.label)).split("\n", 1)[0]?.trim();
76
+ return label ? `[${label}]` : undefined;
77
+ }
45
78
  function toastMessageLines(message, icon, maxWidth) {
46
79
  const firstPrefix = `${icon} `;
47
80
  const continuationPrefix = " ".repeat(stringDisplayWidth(firstPrefix));
@@ -53,6 +53,7 @@ export type AppMouseControllerHost = {
53
53
  variant?: ToastVariant;
54
54
  }): void;
55
55
  dismissToast(toastId: number): void;
56
+ activateToastAction(toastId: number): boolean;
56
57
  refreshModelUsageStatus(): void | Promise<void>;
57
58
  refreshUserMessageJumpMenuItems?(): Promise<void>;
58
59
  queueInputFromStatus?(): void | Promise<void>;
@@ -117,6 +117,11 @@ export class AppMouseController {
117
117
  return;
118
118
  if (target.action === "body")
119
119
  return;
120
+ if (target.action === "action") {
121
+ if (this.host.activateToastAction(target.id))
122
+ this.showClickFlashForEvent(event);
123
+ return;
124
+ }
120
125
  if (this.copyErrorToast(target.id)) {
121
126
  this.showClickFlashForEvent(event);
122
127
  return;
@@ -4,6 +4,7 @@ import type { Entry, SessionActivity, SubmittedUserMessage } from "../types.js";
4
4
  export type AppQueuedMessageControllerHost = {
5
5
  runtime(): AgentSessionRuntime | undefined;
6
6
  requireRuntime(): AgentSessionRuntime;
7
+ awaitCurrentSessionExtensions(runtime: AgentSessionRuntime): Promise<void>;
7
8
  visibleEntries(): readonly Entry[];
8
9
  isRunning(): boolean;
9
10
  render(): void;
@@ -59,6 +59,9 @@ export class AppQueuedMessageController {
59
59
  this.promptSubmissionsInFlight.add(targetSession);
60
60
  this.host.setSessionActivity("running");
61
61
  try {
62
+ const runtime = this.host.runtime();
63
+ if (runtime?.session === targetSession)
64
+ await this.host.awaitCurrentSessionExtensions(runtime);
62
65
  const opts = {};
63
66
  if (targetSession.isStreaming)
64
67
  opts.streamingBehavior = options.streamingBehavior ?? "steer";
@@ -29,6 +29,7 @@ export type AppSessionEventControllerState = {
29
29
  export type AppSessionEventControllerHost = {
30
30
  readonly entries: Entry[];
31
31
  runtime(): AgentSessionRuntime | undefined;
32
+ awaitSessionExtensions?(runtime: AgentSessionRuntime): Promise<void>;
32
33
  conversationViewport(): ConversationViewport;
33
34
  conversationViewportColumns?(): number;
34
35
  onHistoryWindowPruned?(edge: "top" | "bottom", lineCount: number): void;
@@ -63,7 +64,12 @@ export type AppSessionEventControllerHost = {
63
64
  showSnapshot?: boolean;
64
65
  }): void;
65
66
  observeTodoToolResult(toolName: string, details: unknown, isError?: boolean): void;
66
- showToast(message: string, kind: "success" | "error" | "warning" | "info"): void;
67
+ showToast(message: string, kind: "success" | "error" | "warning" | "info", options?: {
68
+ action?: {
69
+ label: string;
70
+ onSelect: () => void;
71
+ };
72
+ }): void;
67
73
  };
68
74
  export declare class AppSessionEventController {
69
75
  private readonly host;
@@ -118,6 +124,9 @@ export declare class AppSessionEventController {
118
124
  render?: boolean;
119
125
  }): Promise<boolean>;
120
126
  handleSessionEvent(event: AgentSessionEvent): void;
127
+ private retryFailedTurn;
128
+ private retryFailedTurnAsync;
129
+ private isActiveRuntimeSession;
121
130
  addCustomMessageEntry(message: Record<string, unknown>): void;
122
131
  findEntry(id: string): Entry | undefined;
123
132
  findUserEntry(id: string): Extract<Entry, {
@@ -328,18 +328,70 @@ export class AppSessionEventController {
328
328
  this.host.setStatus(`retry ${event.attempt}/${event.maxAttempts}`);
329
329
  this.host.emitExtensionEvent(RETRY_ACTIVE_EVENT, { active: true });
330
330
  break;
331
- case "auto_retry_end":
331
+ case "auto_retry_end": {
332
332
  this.host.setSessionActivity(this.host.runtime()?.session.isStreaming ? "running" : "idle");
333
333
  this.host.restoreSessionStatus();
334
334
  this.host.flushAutoUserMessages();
335
335
  this.host.emitExtensionEvent(RETRY_ACTIVE_EVENT, { active: false });
336
- this.host.showToast(event.success ? "Retry succeeded" : `Retry failed: ${event.finalError}`, event.success ? "success" : "error");
336
+ if (event.success) {
337
+ this.host.showToast("Retry succeeded", "success");
338
+ break;
339
+ }
340
+ const session = this.host.runtime()?.session;
341
+ this.host.showToast(`Retry failed: ${event.finalError}`, "error", session
342
+ ? { action: { label: "Retry", onSelect: () => this.retryFailedTurn(session) } }
343
+ : undefined);
337
344
  break;
345
+ }
338
346
  default:
339
347
  break;
340
348
  }
341
349
  this.host.scheduleRender();
342
350
  }
351
+ retryFailedTurn(session) {
352
+ void this.retryFailedTurnAsync(session);
353
+ }
354
+ async retryFailedTurnAsync(session) {
355
+ const runtime = this.host.runtime();
356
+ if (!runtime || !this.isActiveRuntimeSession(runtime, session))
357
+ return;
358
+ if (session.isStreaming || session.isCompacting) {
359
+ this.host.showToast("Retry is unavailable while the session is busy", "warning");
360
+ return;
361
+ }
362
+ this.host.setStatus("retrying failed turn");
363
+ this.host.setSessionActivity("running");
364
+ this.host.render();
365
+ try {
366
+ await this.host.awaitSessionExtensions?.(runtime);
367
+ if (!this.isActiveRuntimeSession(runtime, session))
368
+ return;
369
+ if (session.isStreaming || session.isCompacting) {
370
+ this.host.showToast("Retry is unavailable while the session is busy", "warning");
371
+ return;
372
+ }
373
+ await session.sendCustomMessage({
374
+ customType: "pix-retry",
375
+ content: "Continue the previous task from where you stopped.",
376
+ display: false,
377
+ }, { triggerTurn: true });
378
+ }
379
+ catch (error) {
380
+ if (this.isActiveRuntimeSession(runtime, session)) {
381
+ this.host.showToast(`Retry could not start: ${stringifyUnknown(error)}`, "error");
382
+ }
383
+ }
384
+ finally {
385
+ if (this.isActiveRuntimeSession(runtime, session)) {
386
+ this.host.setSessionStatus(session);
387
+ this.host.setSessionActivity(session.isStreaming || session.isCompacting ? "running" : "idle");
388
+ this.host.render();
389
+ }
390
+ }
391
+ }
392
+ isActiveRuntimeSession(runtime, session) {
393
+ return this.host.isRunning() && this.host.runtime() === runtime && runtime.session === session;
394
+ }
343
395
  addCustomMessageEntry(message) {
344
396
  const entry = customMessageEntry(message);
345
397
  if (entry)
@@ -243,7 +243,7 @@ export type RenderedLine = {
243
243
  export type ToastLineTarget = {
244
244
  kind: "toast";
245
245
  id: number;
246
- action?: "toast" | "body" | "close";
246
+ action?: "toast" | "body" | "close" | "action";
247
247
  startColumn?: number;
248
248
  endColumn?: number;
249
249
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ui-extend",
3
- "version": "0.1.78",
3
+ "version": "1.0.1",
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": {