@oh-my-pi/pi-tui 18.2.7 → 18.2.9

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/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.2.9] - 2026-09-22
6
+
7
+ ### Added
8
+
9
+ - Added Claude and Codex saved-reset counts, availability, and expiry to usage views, with provider-specific confirmation and uniquely identified account options.
10
+ - Added terminal detection and capability support for otty and rio, including Kitty graphics and true-color support where available, plus otty hyperlinks and notifications.
11
+ - Added the public `stripTerminalSequences` export for extensions that need to remove terminal control sequences.
12
+
13
+ ### Fixed
14
+
15
+ - Composer shape previews now use the full available overlay width instead of being clipped at 96 columns.
16
+ - Fixed cursor placement when recalling prompts from history, keeping single-line entries at the end and preserving the appropriate position for multi-line entries.
17
+ - Restored modified-key handling and terminal notifications over SSH sessions running inside tmux.
18
+ - Fixed typed Enter occasionally being interpreted as a literal newline when terminal input events are batched.
19
+
20
+ ## [18.2.8] - 2026-09-21
21
+
22
+ ### Changed
23
+
24
+ - Improved Bash tool background-task notices by providing completed output as a follow-up and discouraging unnecessary polling.
25
+
5
26
  ## [18.2.7] - 2026-09-21
6
27
 
7
28
  ### Breaking Changes
@@ -26,7 +26,7 @@ export interface KittyGraphicsFeatures {
26
26
  * Whether the detected terminal renders Kitty Unicode placeholders (`U=1` +
27
27
  * U+10EEEE with row/column diacritics).
28
28
  *
29
- * Kitty and Ghostty advertise placeholder support directly. A multiplexer
29
+ * Kitty, Ghostty and Otty advertise placeholder support directly. A multiplexer
30
30
  * cannot use cursor-positioned placements because the outer terminal does not
31
31
  * know pane scroll/reflow state. An explicit `PI_FORCE_IMAGE_PROTOCOL=kitty`
32
32
  * opts into placeholders under any multiplexer — matching `timg -pk`.
@@ -1,18 +1,30 @@
1
+ import type { UsageResetCreditDetail } from "@oh-my-pi/pi-ai";
1
2
  import { OverlayPanel } from "../chrome/overlay-box.js";
2
3
  /** One account row with its redeemable rate-limit reset credits. */
3
4
  export interface ResetUsageAccount {
4
5
  label: string;
6
+ provider: string;
7
+ providerLabel: string;
8
+ /** Banked resets, including grants that cannot be spent right now. */
5
9
  availableCount: number;
10
+ /** Resets the provider currently permits this account to spend. */
11
+ redeemableCount: number;
6
12
  target: {
7
- credentialId?: number;
13
+ credentialId: number;
14
+ provider: string;
15
+ creditId?: string;
8
16
  accountId?: string;
9
17
  email?: string;
18
+ orgId?: string;
10
19
  };
11
20
  active: boolean;
12
21
  error?: string;
22
+ unavailableReason?: string;
23
+ expiresAt?: string;
24
+ credit?: UsageResetCreditDetail;
13
25
  }
14
26
  /**
15
- * Account picker for `/usage reset`. Lists Codex accounts with their saved
27
+ * Account picker for `/usage reset`. Lists provider accounts with their saved
16
28
  * rate-limit reset counts; selecting one redeems a reset. Because a reset is a
17
29
  * scarce, irreversible credit, Enter requires a second press to confirm.
18
30
  */
@@ -32,6 +32,12 @@ export interface ProviderCard {
32
32
  unlimited: boolean;
33
33
  /** True when nothing is used anywhere (or there are no limits): collapses to a tick. */
34
34
  idle: boolean;
35
+ resetCredits?: {
36
+ bankedCount: number;
37
+ redeemableCount: number;
38
+ soonestExpiryMs?: number;
39
+ unavailableReasons: string[];
40
+ };
35
41
  }
36
42
  /**
37
43
  * Collapse usage reports into one compact card per provider: limits grouped by
@@ -1,5 +1,20 @@
1
- import type { UsageLimit, UsageReport } from "@oh-my-pi/pi-ai";
1
+ import type { UsageLimit, UsageReport, UsageResetCredits } from "@oh-my-pi/pi-ai";
2
2
  /** Include the usage tier in a limit title unless its label already names it. */
3
3
  export declare function formatLimitTitle(limit: UsageLimit): string;
4
+ /** Human-readable quota scope for a normalized saved-reset window ID. */
5
+ export declare function formatUsageResetWindow(windowId: string): string;
6
+ /** Saved inventory and current eligibility shared by every usage display. */
7
+ export interface UsageResetSummary {
8
+ bankedCount: number;
9
+ redeemableCount: number;
10
+ soonestExpiry?: string;
11
+ unavailableReason?: string;
12
+ }
13
+ /**
14
+ * Normalize old count-only and current provider reset metadata for display.
15
+ * `availableCount` is banked inventory; `redeemableCount` is the subset that
16
+ * may be spent now. Older providers used `availableCount` for both.
17
+ */
18
+ export declare function summarizeUsageResetCredits(reset: UsageResetCredits | undefined, nowMs?: number): UsageResetSummary | undefined;
4
19
  /** Collapse routing-specific copies of a shared quota for user-facing usage views. */
5
20
  export declare function collapseSharedUsageReports(reports: UsageReport[]): UsageReport[];
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Original filesystem source carried on image attachments that entered from a
3
+ * file on disk: path-pasted/drag-and-dropped images and generated video
4
+ * contact-sheet previews. Symbol metadata stays out of serialized/model-bound
5
+ * image data while traveling with the draft object, until AgentSession creates
6
+ * the hidden companion message that tells the model the path (and link
7
+ * materialization prefers it over a blob copy).
8
+ */
9
+ import type { ImageContent } from "@oh-my-pi/pi-ai";
10
+ /** How an image attachment's backing file entered the session. */
11
+ export type ImageAttachmentSourceKind = "image" | "video";
12
+ /** Original local file backing an image attachment. */
13
+ export interface ImageAttachmentSource {
14
+ readonly path: string;
15
+ readonly kind: ImageAttachmentSourceKind;
16
+ }
17
+ declare const kImageAttachmentSource: unique symbol;
18
+ /** An image attachment tagged with the original local file it came from. */
19
+ export type SourceTaggedImage = ImageContent & {
20
+ readonly [kImageAttachmentSource]: ImageAttachmentSource;
21
+ };
22
+ /** Create a model-ready image tagged with the local file it was loaded from. */
23
+ export declare function tagImageAttachmentSource(image: ImageContent, path: string, kind: ImageAttachmentSourceKind): SourceTaggedImage;
24
+ /** Return the original local source of an image attachment, or undefined for payloads with no source file (clipboard bitmaps). */
25
+ export declare function imageAttachmentSource(image: ImageContent): ImageAttachmentSource | undefined;
26
+ export {};
@@ -1,18 +1,14 @@
1
1
  import type { ImageContent } from "@oh-my-pi/pi-ai";
2
+ import { type SourceTaggedImage } from "./image-source.js";
2
3
  /** True when the path names a video container we handle through ffmpeg. */
3
4
  export declare function isVideoPath(filePath: string): boolean;
4
- /**
5
- * Original local source path stored on a generated contact-sheet image. Symbol
6
- * metadata stays out of serialized/model-bound image data while traveling with
7
- * the draft object until AgentSession creates its hidden companion message.
8
- */
9
- declare const kVideoPreviewSource: unique symbol;
10
5
  /** A contact-sheet image tagged with the original local video path. */
11
- export type VideoPreviewImage = ImageContent & {
12
- readonly [kVideoPreviewSource]: string;
13
- };
6
+ export type VideoPreviewImage = SourceTaggedImage;
14
7
  /** Create a model-ready contact-sheet image tagged with its original video path. */
15
8
  export declare function createVideoPreviewImage(preview: ImageContent, sourcePath: string): VideoPreviewImage;
16
- /** Return the original video path associated with a generated contact-sheet image. */
9
+ /**
10
+ * Return the original video path associated with a generated contact-sheet
11
+ * image, via the shared attachment-source tag (see {@link tagImageAttachmentSource}).
12
+ * Returns undefined for untagged images and for image-file (non-video) sources.
13
+ */
17
14
  export declare function videoPreviewSource(preview: ImageContent): string | undefined;
18
- export {};
@@ -209,6 +209,13 @@ export interface SegmentContext {
209
209
  percent: number;
210
210
  resetHours?: number;
211
211
  };
212
+ resetCredits?: {
213
+ bankedCount: number;
214
+ redeemableCount: number;
215
+ expiryHours?: number;
216
+ expired?: boolean;
217
+ unavailableReason?: string;
218
+ };
212
219
  } | null;
213
220
  }
214
221
  export interface RenderedSegment {
@@ -62,6 +62,15 @@ export declare class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
62
62
  #private;
63
63
  constructor(options?: StdinBufferOptions);
64
64
  process(data: string | Buffer): void;
65
+ /**
66
+ * Enable or disable unbracketed raw-paste classification. Terminal disables
67
+ * it once DECRQM confirms bracketed-paste (mode 2004) support: a genuine
68
+ * paste then always arrives wrapped, so the heuristic can only misfire on
69
+ * keystrokes an event-loop stall batched into one read (issue #12540).
70
+ * Disabling flushes any candidate already held by the classification window
71
+ * as ordinary key events so no buffered input is lost.
72
+ */
73
+ setRawPasteClassification(enabled: boolean): void;
65
74
  flush(): string[];
66
75
  clear(): void;
67
76
  getBuffer(): string;
@@ -11,7 +11,7 @@ export declare enum NotifyProtocol {
11
11
  Osc99 = "\u001B]99;;",
12
12
  Osc9 = "\u001B]9;"
13
13
  }
14
- export type TerminalId = "kitty" | "ghostty" | "wezterm" | "iterm2" | "vscode" | "alacritty" | "warp" | "orca" | "base" | "trueColor";
14
+ export type TerminalId = "kitty" | "ghostty" | "wezterm" | "iterm2" | "vscode" | "alacritty" | "warp" | "orca" | "otty" | "rio" | "base" | "trueColor";
15
15
  /** Terminal capability details used for rendering and protocol selection. */
16
16
  export declare class TerminalInfo {
17
17
  readonly id: TerminalId;
@@ -4,3 +4,11 @@ export declare function isInsideTmux(env?: NodeJS.ProcessEnv): boolean;
4
4
  export declare function wrapTmuxPassthrough(payload: string): string;
5
5
  /** Pass a control sequence through tmux, leaving direct-terminal output unchanged. */
6
6
  export declare function wrapTmuxPassthroughIfNeeded(payload: string, env?: NodeJS.ProcessEnv): string;
7
+ /**
8
+ * Resolve the terminal emulator name recorded for this tmux client.
9
+ *
10
+ * tmux overwrites pane identity variables with its own values, but retains the
11
+ * attached client's terminal-type reply in `#{client_termtype}`. The local IPC
12
+ * query runs once per process and degrades to `null` when unavailable.
13
+ */
14
+ export declare function resolveTmuxClientTerminalName(env?: NodeJS.ProcessEnv): string | null;
@@ -11,7 +11,7 @@ export interface FindRange {
11
11
  }
12
12
  /** A file whose verified passages cleared the threshold; `ranges` are merged positive spans, strongest first. */
13
13
  export interface FindHit {
14
- /** Display path relative to {@link FindToolDetails.cwd}. */
14
+ /** Display path relative to {@link FindToolDetails.cwd}, or an `omp://` doc URL for docs scopes. */
15
15
  rel: string;
16
16
  /** Filename judgment, when the name batch answered. */
17
17
  nameScore?: number;
@@ -44,6 +44,8 @@ export interface JobSnapshot {
44
44
  status: "running" | "completed" | "failed" | "cancelled";
45
45
  label: string;
46
46
  durationMs: number;
47
+ /** Process exit status when the job reports one. */
48
+ exitCode?: number;
47
49
  /** Effective task model selector, including an explicit reasoning suffix when configured. */
48
50
  resolvedModel?: string;
49
51
  /** Provider/id including routing, with no added thinking suffix. */
@@ -49,6 +49,8 @@ export declare function getSegmenter(): Intl.Segmenter;
49
49
  * sequences (counted as printable by Bun, actually zero cells).
50
50
  */
51
51
  export declare function visibleWidth(str: string): number;
52
+ /** Remove ANSI, OSC, and APC control sequences while preserving visible text. */
53
+ export declare function stripTerminalSequences(str: string): string;
52
54
  /**
53
55
  * Scaled spans must bypass wrapping/padding and, when scaled up, reserve the
54
56
  * terminal rows their multicell glyphs flow into.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "18.2.7",
4
+ "version": "18.2.9",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Stencil Labs, Inc.",
@@ -37,14 +37,14 @@
37
37
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/omptype": "18.2.7",
41
- "@oh-my-pi/pi-agent-core": "18.2.7",
42
- "@oh-my-pi/pi-ai": "18.2.7",
43
- "@oh-my-pi/pi-catalog": "18.2.7",
44
- "@oh-my-pi/pi-natives": "18.2.7",
45
- "@oh-my-pi/pi-utils": "18.2.7",
46
- "@oh-my-pi/pi-wire": "18.2.7",
47
- "@oh-my-pi/snapcompact": "18.2.7"
40
+ "@oh-my-pi/omptype": "18.2.9",
41
+ "@oh-my-pi/pi-agent-core": "18.2.9",
42
+ "@oh-my-pi/pi-ai": "18.2.9",
43
+ "@oh-my-pi/pi-catalog": "18.2.9",
44
+ "@oh-my-pi/pi-natives": "18.2.9",
45
+ "@oh-my-pi/pi-utils": "18.2.9",
46
+ "@oh-my-pi/pi-wire": "18.2.9",
47
+ "@oh-my-pi/snapcompact": "18.2.9"
48
48
  },
49
49
  "devDependencies": {
50
50
  "kitty-vt-wasm": "^0.2.0"
@@ -951,6 +951,9 @@ export class Editor implements Component, Focusable {
951
951
  this.restoreHistoryState(entry?.draft?.restore);
952
952
  this.#historyDraftActive = entry?.draft !== undefined;
953
953
  }
954
+ // Browsing asks for the edge the key came from, so one press still steps one
955
+ // entry: Up opens a multi-row entry at its top, Down at its bottom (#99).
956
+ // #setTextInternal drops that request for single-row entries — see there.
954
957
  const cursorAnchor: HistoryCursorAnchor = direction === -1 ? "start" : "end";
955
958
  this.#setTextInternal(entry?.text ?? "", cursorAnchor);
956
959
  }
@@ -959,7 +962,13 @@ export class Editor implements Component, Focusable {
959
962
  this.#undoStack.length = 0;
960
963
  const lines = sanitizeLoadedText(text).split("\n");
961
964
  this.#state.lines = lines.length === 0 ? [""] : lines;
962
- if (cursorAnchor === "start") {
965
+ // A single-row entry's top and bottom are the same row, so the directional
966
+ // anchor degenerates to a bare column choice: the caret would sit at the start
967
+ // when Up recalled the entry and at the end when Down reached the same entry,
968
+ // leaving delete/yank commands aimed at column 0 on one path and at the tail on
969
+ // the other. Single-row entries always open at the end — matching what
970
+ // wholesale text replacement (`setText`) and the first-edit anchor do.
971
+ if (cursorAnchor === "start" && this.#spansMultipleVisualRows()) {
963
972
  this.#state.cursorLine = 0;
964
973
  this.#setCursorCol(0);
965
974
  } else {
@@ -969,6 +978,17 @@ export class Editor implements Component, Focusable {
969
978
  this.#notifyChange();
970
979
  }
971
980
 
981
+ /** Whether the buffer needs more than one visual row at the last painted layout
982
+ * width. The directional history anchors are load-bearing only for such entries —
983
+ * they keep one keypress stepping one entry instead of walking inside it — so a
984
+ * newline-free line that wraps past the editor width counts as multi-row too. */
985
+ #spansMultipleVisualRows(): boolean {
986
+ if (this.#state.lines.length > 1) return true;
987
+ const width = this.#lastLayoutWidth;
988
+ if (width <= 0) return false;
989
+ return this.#layoutText(width).length > 1;
990
+ }
991
+
972
992
  invalidate(): void {
973
993
  // No cached state to invalidate currently
974
994
  }
@@ -1114,8 +1134,12 @@ export class Editor implements Component, Focusable {
1114
1134
  const lastGrapheme = beforeGraphemes[beforeGraphemes.length - 1]?.segment;
1115
1135
  const lastGraphemeWidth = lastGrapheme ? visibleWidth(lastGrapheme) : 0;
1116
1136
  const builtInCursor = this.#getStyledInputCursor();
1137
+ // The end-of-line cursor borrows the last grapheme's cell, which the
1138
+ // on-character cursor also highlights with reverse video. Underline the
1139
+ // borrowed cell instead so insertion after the last character stays
1140
+ // visually distinct from insertion before it.
1117
1141
  const fallbackReplacement = lastGrapheme
1118
- ? { text: this.#cursorCell(lastGrapheme), width: lastGraphemeWidth }
1142
+ ? { text: `\x1b[4m${lastGrapheme}\x1b[0m`, width: lastGraphemeWidth }
1119
1143
  : builtInCursor;
1120
1144
  const clampReplacement = (candidate: { text: string; width: number }): { text: string; width: number } => {
1121
1145
  let text = sliceByColumn(candidate.text, 0, maxWidth, true);
@@ -1136,7 +1160,6 @@ export class Editor implements Component, Focusable {
1136
1160
  // If even the highlighted trailing grapheme cannot fit, show the built-in single-column cursor.
1137
1161
  clampedReplacement = clampReplacement(builtInCursor);
1138
1162
  }
1139
-
1140
1163
  const replacedSpanWidth = Math.min(maxWidth, Math.max(lastGraphemeWidth, clampedReplacement.width));
1141
1164
  const prefixWidth = Math.max(0, maxWidth - replacedSpanWidth);
1142
1165
  const beforePrefix = sliceByColumn(before, 0, prefixWidth, true);
@@ -1152,17 +1175,14 @@ export class Editor implements Component, Focusable {
1152
1175
  if (visibleWidth(text) < maxWidth) {
1153
1176
  return text + marker;
1154
1177
  }
1155
-
1156
- let insertAt = text.length;
1157
- let offset = 0;
1158
- for (const seg of segmenter.segment(text)) {
1159
- if (visibleWidth(seg.segment) > 0) {
1160
- insertAt = offset;
1161
- }
1162
- offset += seg.segment.length;
1163
- }
1164
-
1165
- return `${text.slice(0, insertAt)}${marker}${text.slice(insertAt)}`;
1178
+ // The row is exactly full, so the marker lands before the last visible
1179
+ // grapheme instead of after it. The mirrored on-character position renders
1180
+ // the identical string; underline the final grapheme at end-of-line so the
1181
+ // two insertion points stay visually distinct.
1182
+ const graphemes = [...segmenter.segment(text)];
1183
+ const lastGrapheme = graphemes[graphemes.length - 1]?.segment;
1184
+ if (lastGrapheme === undefined) return text + marker;
1185
+ return `${text.slice(0, text.length - lastGrapheme.length)}\x1b[4m${lastGrapheme}\x1b[0m${marker}`;
1166
1186
  }
1167
1187
 
1168
1188
  #getPageScrollStep(totalVisualLines: number): number {
@@ -63,7 +63,7 @@ export interface KittyGraphicsFeatures {
63
63
  * Whether the detected terminal renders Kitty Unicode placeholders (`U=1` +
64
64
  * U+10EEEE with row/column diacritics).
65
65
  *
66
- * Kitty and Ghostty advertise placeholder support directly. A multiplexer
66
+ * Kitty, Ghostty and Otty advertise placeholder support directly. A multiplexer
67
67
  * cannot use cursor-positioned placements because the outer terminal does not
68
68
  * know pane scroll/reflow state. An explicit `PI_FORCE_IMAGE_PROTOCOL=kitty`
69
69
  * opts into placeholders under any multiplexer — matching `timg -pk`.
@@ -82,7 +82,7 @@ export function detectKittyUnicodePlaceholdersSupport(terminalId: string, env: N
82
82
  const insideMultiplexer = isInsideTerminalMultiplexer(env);
83
83
  if (insideMultiplexer && env.PI_FORCE_IMAGE_PROTOCOL?.trim().toLowerCase() === "kitty") return true;
84
84
  if (isInsideHerdr(env)) return false;
85
- return terminalId === "kitty" || terminalId === "ghostty";
85
+ return terminalId === "kitty" || terminalId === "ghostty" || terminalId === "otty" || terminalId === "rio";
86
86
  }
87
87
 
88
88
  let features: KittyGraphicsFeatures = {
@@ -49,7 +49,9 @@ export function renderComposerShapePreview(
49
49
  width: number,
50
50
  status?: ComposerPreviewStatusSource,
51
51
  ): readonly string[] {
52
- const previewWidth = Math.max(24, Math.min(width, 96));
52
+ // No upper cap: the hosting overlay already bounds width by the terminal,
53
+ // and capping clips the status band the preview exists to show (#12500).
54
+ const previewWidth = Math.max(24, width);
53
55
  const style = getComposerStyle(shape);
54
56
  const paddingX = style.defaultPaddingX(undefined);
55
57
  const chromeWidth = style.sideChromeWidth(paddingX);
@@ -1,27 +1,41 @@
1
- import { Container, matchesKey, ScrollView, Spacer, TruncatedText } from "../index";
1
+ import type { UsageResetCreditDetail } from "@oh-my-pi/pi-ai";
2
+ import { Container, matchesKey, ScrollView, Spacer, Text, TruncatedText } from "../index";
3
+ import { formatDuration, sanitizeText } from "@oh-my-pi/pi-utils";
2
4
  import { theme } from "../theme/theme";
3
5
  import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../keybinding-matchers";
4
6
  import { OverlayPanel } from "../chrome/overlay-box";
5
7
  import { MenuSelection } from "../components/menu-selection";
6
8
  import { centeredViewportRange } from "../components/scroll-viewport";
9
+ import { formatUsageResetWindow } from "./usage-display";
7
10
 
8
11
  const RESET_SELECTOR_MAX_VISIBLE = 10;
9
12
 
10
13
  /** One account row with its redeemable rate-limit reset credits. */
11
14
  export interface ResetUsageAccount {
12
15
  label: string;
16
+ provider: string;
17
+ providerLabel: string;
18
+ /** Banked resets, including grants that cannot be spent right now. */
13
19
  availableCount: number;
20
+ /** Resets the provider currently permits this account to spend. */
21
+ redeemableCount: number;
14
22
  target: {
15
- credentialId?: number;
23
+ credentialId: number;
24
+ provider: string;
25
+ creditId?: string;
16
26
  accountId?: string;
17
27
  email?: string;
28
+ orgId?: string;
18
29
  };
19
30
  active: boolean;
20
31
  error?: string;
32
+ unavailableReason?: string;
33
+ expiresAt?: string;
34
+ credit?: UsageResetCreditDetail;
21
35
  }
22
36
 
23
37
  /**
24
- * Account picker for `/usage reset`. Lists Codex accounts with their saved
38
+ * Account picker for `/usage reset`. Lists provider accounts with their saved
25
39
  * rate-limit reset counts; selecting one redeems a reset. Because a reset is a
26
40
  * scarce, irreversible credit, Enter requires a second press to confirm.
27
41
  */
@@ -36,18 +50,17 @@ export class ResetUsageSelectorComponent extends OverlayPanel {
36
50
  super("Spend a saved rate-limit reset");
37
51
  this.#onSelectCallback = onSelect;
38
52
  this.#onCancelCallback = onCancel;
39
- const firstRedeemable = accounts.find(account => account.availableCount > 0);
53
+ const firstRedeemable = accounts.find(account => account.redeemableCount > 0);
54
+ const accountKey = (account: ResetUsageAccount) =>
55
+ `${account.provider}:${account.target.credentialId}:${account.target.creditId ?? ""}`;
40
56
  this.#menu = new MenuSelection<ResetUsageAccount>(
41
57
  accounts,
42
58
  {
43
- getKey: account =>
44
- `${account.target.credentialId ?? ""}:${account.target.accountId ?? ""}:${account.target.email ?? ""}:${account.label}`,
45
- getSearchText: account => account.label,
46
- requiresConfirmation: account => account.availableCount > 0,
59
+ getKey: accountKey,
60
+ getSearchText: account => `${account.label} ${account.providerLabel}`,
61
+ requiresConfirmation: account => account.redeemableCount > 0,
47
62
  },
48
- firstRedeemable
49
- ? `${firstRedeemable.target.credentialId ?? ""}:${firstRedeemable.target.accountId ?? ""}:${firstRedeemable.target.email ?? ""}:${firstRedeemable.label}`
50
- : undefined,
63
+ firstRedeemable ? accountKey(firstRedeemable) : undefined,
51
64
  );
52
65
 
53
66
  this.#listContainer = new Container();
@@ -57,6 +70,7 @@ export class ResetUsageSelectorComponent extends OverlayPanel {
57
70
 
58
71
  #updateList(): void {
59
72
  this.#listContainer.clear();
73
+ const oneLine = (value: string): string => sanitizeText(value.replace(/[\r\n\t]+/g, " "));
60
74
 
61
75
  const items = this.#menu.visibleItems;
62
76
  const total = items.length;
@@ -68,22 +82,43 @@ export class ResetUsageSelectorComponent extends OverlayPanel {
68
82
  const account = items[i];
69
83
  if (!account) continue;
70
84
  const isSelected = i === this.#menu.selectedIndex;
71
- const redeemable = account.availableCount > 0;
72
- const countLabel = account.error
73
- ? account.error
74
- : `${account.availableCount} saved reset${account.availableCount === 1 ? "" : "s"}`;
85
+ const redeemable = account.redeemableCount > 0;
86
+ let countLabel: string;
87
+ if (account.error) {
88
+ countLabel = oneLine(account.error);
89
+ } else {
90
+ countLabel = `${account.availableCount} saved reset${account.availableCount === 1 ? "" : "s"}`;
91
+ if (account.redeemableCount !== account.availableCount) {
92
+ countLabel += ` · ${account.redeemableCount} usable now`;
93
+ }
94
+ if (account.expiresAt) {
95
+ const expiryMs = Date.parse(account.expiresAt);
96
+ if (!Number.isNaN(expiryMs)) {
97
+ countLabel +=
98
+ expiryMs > Date.now() ? ` · expires in ${formatDuration(expiryMs - Date.now())}` : " · expired";
99
+ }
100
+ }
101
+ if (!redeemable && account.unavailableReason) {
102
+ countLabel += ` · ${oneLine(account.unavailableReason)}`;
103
+ }
104
+ }
75
105
  const countText = account.error
76
106
  ? theme.fg("error", countLabel)
77
107
  : redeemable
78
108
  ? theme.fg("success", countLabel)
79
109
  : theme.fg("dim", countLabel);
80
110
  const activeTag = account.active ? theme.fg("muted", " (active)") : "";
111
+ const safeLabel = oneLine(account.label);
112
+ const providerTag = theme.fg(
113
+ "muted",
114
+ `[${oneLine(account.providerLabel)} · #${account.target.credentialId}] `,
115
+ );
81
116
  if (isSelected) {
82
- const name = redeemable ? theme.fg("accent", account.label) : theme.fg("dim", account.label);
83
- rows.push(`${theme.fg("accent", `${theme.nav.cursor} `)}${name}${activeTag} ${countText}`);
117
+ const name = redeemable ? theme.fg("accent", safeLabel) : theme.fg("dim", safeLabel);
118
+ rows.push(`${theme.fg("accent", `${theme.nav.cursor} `)}${providerTag}${name}${activeTag} ${countText}`);
84
119
  } else {
85
- const name = redeemable ? ` ${account.label}` : theme.fg("dim", ` ${account.label}`);
86
- rows.push(`${name}${activeTag} ${countText}`);
120
+ const name = redeemable ? safeLabel : theme.fg("dim", safeLabel);
121
+ rows.push(` ${providerTag}${name}${activeTag} ${countText}`);
87
122
  }
88
123
  }
89
124
 
@@ -100,20 +135,35 @@ export class ResetUsageSelectorComponent extends OverlayPanel {
100
135
 
101
136
  if (total === 0) {
102
137
  this.#listContainer.addChild(
103
- new TruncatedText(theme.fg("muted", "No Codex accounts with saved resets"), 0, 0),
138
+ new TruncatedText(theme.fg("muted", "No provider accounts with saved resets"), 0, 0),
104
139
  );
105
140
  }
106
141
 
107
142
  const pending = items.find(item => this.#menu.isPending(item));
108
143
  const hint = pending
109
- ? theme.fg("warning", `Press Enter again to spend 1 reset for ${pending.label}, Esc to cancel`)
144
+ ? theme.fg("warning", oneLine(this.#confirmationMessage(pending)))
110
145
  : theme.fg("muted", "↑/↓ select · ↵ spend a reset · Esc cancel");
111
- this.#listContainer.addChild(new TruncatedText(hint, 0, 0));
146
+ this.#listContainer.addChild(new Text(hint, 0, 0));
112
147
 
113
148
  if (this.#statusMessage) {
114
149
  this.#listContainer.addChild(new Spacer(1));
115
- this.#listContainer.addChild(new TruncatedText(theme.fg("warning", this.#statusMessage), 0, 0));
150
+ this.#listContainer.addChild(new Text(theme.fg("warning", oneLine(this.#statusMessage)), 0, 0));
151
+ }
152
+ }
153
+
154
+ #confirmationMessage(account: ResetUsageAccount): string {
155
+ const subject = account.credit?.title ? `“${account.credit.title}”` : "1 saved reset";
156
+ const messages = [`Press Enter again to spend ${subject} for ${account.label} (${account.providerLabel}).`];
157
+ if (account.credit?.program === "juniper_tide") {
158
+ messages.push("This resets Claude's 5h session limit only; weekly limits stay unchanged.");
159
+ } else if (account.credit?.clears?.length) {
160
+ messages.push(`Covers ${account.credit.clears.map(formatUsageResetWindow).join(", ")}.`);
161
+ }
162
+ if (account.credit?.requiresLimit === false) {
163
+ messages.push("Optional early use: this can be spent before the covered limit is reached.");
116
164
  }
165
+ messages.push("Esc cancels.");
166
+ return messages.join(" ");
117
167
  }
118
168
 
119
169
  handleInput(keyData: string): void {
@@ -150,8 +200,10 @@ export class ResetUsageSelectorComponent extends OverlayPanel {
150
200
  } else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
151
201
  const account = this.#menu.selectedItem;
152
202
  if (!account) return;
153
- if (account.availableCount <= 0) {
154
- this.#statusMessage = "That account has no saved resets to spend.";
203
+ if (account.redeemableCount <= 0) {
204
+ this.#statusMessage = account.unavailableReason
205
+ ? `That account's saved resets are unavailable: ${account.unavailableReason}`
206
+ : "That account has no saved resets usable right now.";
155
207
  this.#updateList();
156
208
  return;
157
209
  }