@oh-my-pi/pi-tui 18.3.1 → 18.3.2

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.
@@ -19,8 +19,10 @@
19
19
  *
20
20
  * Keys: Up/Down step through rendered items in transcript order (within the
21
21
  * active column when a strip is open), Left/Right slide between branch
22
- * variants at a fork and jump between user turns elsewhere, Enter rewinds to
23
- * the outlined item, Esc cancels.
22
+ * variants at a fork and jump between user turns elsewhere, `f` opens a
23
+ * filter (typing narrows the current path to matching items; Esc leaves the
24
+ * filter with the selection kept), Enter rewinds to the outlined item, Esc
25
+ * cancels.
24
26
  */
25
27
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
26
28
  import { type Component, type TUI } from "../index.js";
@@ -63,7 +63,7 @@ export interface StatusLineSession {
63
63
  } | undefined;
64
64
  autoResolvedThinkingLevel(): string | undefined;
65
65
  isFastModeActive(): boolean;
66
- /** Anthropic subscription slow-mode label (`low priority until 14:30 · 62% left`) while active. */
66
+ /** Anthropic usage-limit label (`limit reached · wrapping up · resets 14:30`, `low priority until 14:30 · 62% left`). */
67
67
  getAnthropicSlowModeLabel?(): string | undefined;
68
68
  getPrewalkState?(): unknown;
69
69
  getAsyncJobSnapshot(): {
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.3.1",
4
+ "version": "18.3.2",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -40,14 +40,14 @@
40
40
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
41
41
  },
42
42
  "dependencies": {
43
- "@oh-my-pi/omptype": "18.3.1",
44
- "@oh-my-pi/pi-agent-core": "18.3.1",
45
- "@oh-my-pi/pi-ai": "18.3.1",
46
- "@oh-my-pi/pi-catalog": "18.3.1",
47
- "@oh-my-pi/pi-natives": "18.3.1",
48
- "@oh-my-pi/pi-utils": "18.3.1",
49
- "@oh-my-pi/pi-wire": "18.3.1",
50
- "@oh-my-pi/snapcompact": "18.3.1"
43
+ "@oh-my-pi/omptype": "18.3.2",
44
+ "@oh-my-pi/pi-agent-core": "18.3.2",
45
+ "@oh-my-pi/pi-ai": "18.3.2",
46
+ "@oh-my-pi/pi-catalog": "18.3.2",
47
+ "@oh-my-pi/pi-natives": "18.3.2",
48
+ "@oh-my-pi/pi-utils": "18.3.2",
49
+ "@oh-my-pi/pi-wire": "18.3.2",
50
+ "@oh-my-pi/snapcompact": "18.3.2"
51
51
  },
52
52
  "devDependencies": {
53
53
  "kitty-vt-wasm": "^0.2.0"
@@ -1630,6 +1630,13 @@ export class ModelHubComponent implements Component {
1630
1630
  return;
1631
1631
  }
1632
1632
 
1633
+ // Enter on the sidebar is a pane switch, like →: it lands on the model
1634
+ // rows instead of acting on a row the user cannot see is selected.
1635
+ if (this.#focus === "scope" && (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n")) {
1636
+ this.#focus = "list";
1637
+ return;
1638
+ }
1639
+
1633
1640
  const beforeQuery = this.#browser.query;
1634
1641
  const isPrintable = extractPrintableText(data) !== undefined;
1635
1642
  this.#browser.handleInput(data);
@@ -2274,19 +2281,22 @@ export class ModelHubComponent implements Component {
2274
2281
  return "←/→ thinking level · Enter apply · Esc keep";
2275
2282
  }
2276
2283
  if (this.#assigning !== null) {
2284
+ if (this.#focus === "scope") {
2285
+ return "Enter/→ models · ↑/↓ providers · type to search · Alt+←/→ kind · Esc cancel";
2286
+ }
2277
2287
  switch (this.#assigning.kind) {
2278
2288
  case "fallback":
2279
- return "Enter pick fallback · ↑/↓ providers · type to search · Alt+←/→ kind · Esc cancel";
2289
+ return "Enter pick fallback · ↑/↓ models · ← providers · type to search · Alt+←/→ kind · Esc cancel";
2280
2290
  case "fallbackKey":
2281
- return "Enter pick the protected model · ↑/↓ providers · type to search · Alt+←/→ kind · Esc cancel";
2291
+ return "Enter pick the protected model · ↑/↓ models · ← providers · type to search · Alt+←/→ kind · Esc cancel";
2282
2292
  default:
2283
- return "Enter assign · ↑/↓ providers · type to search · Alt+←/→ kind · Esc cancel";
2293
+ return "Enter assign · ↑/↓ models · ← providers · type to search · Alt+←/→ kind · Esc cancel";
2284
2294
  }
2285
2295
  }
2286
2296
  const entry = this.#activeEntry();
2287
2297
  if (entry.kind === "roles") {
2288
2298
  if (this.#focus !== "list") {
2289
- return "↑/↓ providers · → roles · Alt+←/→ tabs · Esc close";
2299
+ return "↑/↓ providers · Enter/→ roles · Alt+←/→ tabs · Esc close";
2290
2300
  }
2291
2301
  const row = this.#rolesRows[this.#roleIndex];
2292
2302
  if (row?.kind === "fallback") {
@@ -2308,9 +2318,11 @@ export class ModelHubComponent implements Component {
2308
2318
  if (entry.kind === "provider" && entry.locked) {
2309
2319
  return entry.oauth ? "Enter log in · ↑/↓ providers · Esc close" : "↑/↓ providers · Esc close";
2310
2320
  }
2311
- const arrows = this.#focus === "scope" ? "↑/↓ providers · → models" : "↑/↓ models · ← providers";
2312
2321
  const refresh = entry.kind === "provider" ? " · F5 refresh" : "";
2313
- return `Enter assign roles · ${arrows} · type to search · Alt+←/→ kind${refresh} · Esc close`;
2322
+ if (this.#focus === "scope") {
2323
+ return `Enter/→ models · ↑/↓ providers · type to search · Alt+←/→ kind${refresh} · Esc close`;
2324
+ }
2325
+ return `Enter assign roles · ↑/↓ models · ← providers · type to search · Alt+←/→ kind${refresh} · Esc close`;
2314
2326
  }
2315
2327
 
2316
2328
  #renderFooter(width: number): string {
@@ -19,12 +19,15 @@
19
19
  *
20
20
  * Keys: Up/Down step through rendered items in transcript order (within the
21
21
  * active column when a strip is open), Left/Right slide between branch
22
- * variants at a fork and jump between user turns elsewhere, Enter rewinds to
23
- * the outlined item, Esc cancels.
22
+ * variants at a fork and jump between user turns elsewhere, `f` opens a
23
+ * filter (typing narrows the current path to matching items; Esc leaves the
24
+ * filter with the selection kept), Enter rewinds to the outlined item, Esc
25
+ * cancels.
24
26
  */
25
27
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
26
28
  import {
27
29
  type Component,
30
+ extractPrintableText,
28
31
  matchesKey,
29
32
  padding,
30
33
  routeSgrMouseInput,
@@ -110,6 +113,13 @@ export class RewindSelectorComponent implements Component {
110
113
  #slide: { from: number; to: number; startedAt: number } | undefined;
111
114
  #slideTimer: NodeJS.Timeout | undefined;
112
115
 
116
+ /** Filter query while the filter prompt is open; undefined shows the full transcript. */
117
+ #filter: string | undefined;
118
+ /** Main transcript rows from the last frame; the filter matches what is on screen. */
119
+ #mainRows: readonly (readonly string[])[] = [];
120
+ /** Lowercased plain text per rendered child row array (row arrays are cached, so identity is stable). */
121
+ #rowText = new WeakMap<readonly string[], string>();
122
+
113
123
  constructor(
114
124
  entries: TranscriptEntry[],
115
125
  private readonly deps: RewindSelectorDeps,
@@ -235,19 +245,26 @@ export class RewindSelectorComponent implements Component {
235
245
  });
236
246
  return;
237
247
  }
248
+ if (this.#filter !== undefined) {
249
+ this.#handleFilterInput(data);
250
+ return;
251
+ }
238
252
  if (matchesSelectCancel(data) || matchesKey(data, "escape")) {
239
253
  this.deps.onCancel();
240
254
  return;
241
255
  }
242
- if (matchesAppToolsExpand(data)) {
243
- this.#expanded = !this.#expanded;
244
- this.#builder.setExpanded(this.#expanded);
245
- for (const columns of this.#variantCache.values()) {
246
- for (const column of columns) column.builder.setExpanded(this.#expanded);
247
- }
256
+ if (matchesKey(data, "f")) {
257
+ this.#filter = "";
258
+ this.#activeVariant = 0;
259
+ this.#siblingSelected = 0;
260
+ this.#stopSlide();
248
261
  this.deps.requestRender();
249
262
  return;
250
263
  }
264
+ if (matchesAppToolsExpand(data)) {
265
+ this.#toggleExpanded();
266
+ return;
267
+ }
251
268
  if (matchesSelectUp(data)) {
252
269
  this.#moveVertical(-1);
253
270
  return;
@@ -282,6 +299,115 @@ export class RewindSelectorComponent implements Component {
282
299
  }
283
300
  }
284
301
 
302
+ #toggleExpanded(): void {
303
+ this.#expanded = !this.#expanded;
304
+ this.#builder.setExpanded(this.#expanded);
305
+ for (const columns of this.#variantCache.values()) {
306
+ for (const column of columns) column.builder.setExpanded(this.#expanded);
307
+ }
308
+ this.deps.requestRender();
309
+ }
310
+
311
+ // ========================================================================
312
+ // Filter
313
+ // ========================================================================
314
+
315
+ #handleFilterInput(data: string): void {
316
+ if (matchesSelectCancel(data) || matchesKey(data, "escape")) {
317
+ this.#closeFilter();
318
+ return;
319
+ }
320
+ if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
321
+ const target = this.#filterMatches().includes(this.#selected) ? this.#targets[this.#selected] : undefined;
322
+ if (target) this.deps.onSelect(target.entryId);
323
+ return;
324
+ }
325
+ if (matchesAppToolsExpand(data)) {
326
+ this.#toggleExpanded();
327
+ return;
328
+ }
329
+ if (matchesSelectUp(data) || matchesKey(data, "left")) {
330
+ const userTurnsOnly = !matchesSelectUp(data);
331
+ this.#stepFiltered(-1, userTurnsOnly);
332
+ return;
333
+ }
334
+ if (matchesSelectDown(data) || matchesKey(data, "right")) {
335
+ const userTurnsOnly = !matchesSelectDown(data);
336
+ this.#stepFiltered(1, userTurnsOnly);
337
+ return;
338
+ }
339
+ if (matchesKey(data, "backspace")) {
340
+ if (this.#filter!.length === 0) {
341
+ this.#closeFilter();
342
+ return;
343
+ }
344
+ this.#setFilter(this.#filter!.slice(0, -1));
345
+ return;
346
+ }
347
+ const printable = extractPrintableText(data);
348
+ if (printable) {
349
+ this.#setFilter(this.#filter! + printable);
350
+ return;
351
+ }
352
+ if (this.#browser.handleScrollKey(data)) {
353
+ this.deps.requestRender();
354
+ }
355
+ }
356
+
357
+ /** Leave the filter, keeping the selected item outlined in the full transcript. */
358
+ #closeFilter(): void {
359
+ this.#filter = undefined;
360
+ this.deps.requestRender();
361
+ }
362
+
363
+ /** Update the query; keep the selection when it still matches, else rest on the newest match above it. */
364
+ #setFilter(query: string): void {
365
+ this.#filter = query;
366
+ const matches = this.#filterMatches();
367
+ if (!matches.includes(this.#selected)) {
368
+ this.#selected = matches.findLast(index => index < this.#selected) ?? matches.at(-1) ?? this.#selected;
369
+ }
370
+ this.deps.requestRender();
371
+ }
372
+
373
+ #stepFiltered(delta: -1 | 1, userTurnsOnly: boolean): void {
374
+ const matches = this.#filterMatches().filter(index => !userTurnsOnly || this.#targets[index]!.isUserTurn);
375
+ const next =
376
+ delta < 0 ? matches.findLast(index => index < this.#selected) : matches.find(index => index > this.#selected);
377
+ if (next === undefined) return;
378
+ this.#selected = next;
379
+ this.deps.requestRender();
380
+ }
381
+
382
+ /**
383
+ * Visible main-path target indices whose rendered text contains every
384
+ * whitespace-separated query word as a whole word (case-insensitive: `ls`
385
+ * matches `ls -la` but not `tools`); all visible targets for an empty query.
386
+ * Matching the rendered rows keeps results honest: collapsed tool output
387
+ * only matches once Ctrl+O expands it.
388
+ */
389
+ #filterMatches(): number[] {
390
+ const words = (this.#filter ?? "").toLowerCase().split(/\s+/).filter(Boolean);
391
+ const patterns = words.map(
392
+ word => new RegExp(`(?<![\\p{L}\\p{N}_])${RegExp.escape(word)}(?![\\p{L}\\p{N}_])`, "u"),
393
+ );
394
+ const matches: number[] = [];
395
+ for (let index = 0; index < this.#targets.length; index++) {
396
+ if (!this.#isMainSelectable(index)) continue;
397
+ const target = this.#targets[index]!;
398
+ const texts = this.#mainRows.slice(target.start, target.end).map(rows => {
399
+ let text = this.#rowText.get(rows);
400
+ if (text === undefined) {
401
+ text = Bun.stripANSI(rows.join("\n")).toLowerCase();
402
+ this.#rowText.set(rows, text);
403
+ }
404
+ return text;
405
+ });
406
+ if (patterns.every(pattern => texts.some(text => pattern.test(text)))) matches.push(index);
407
+ }
408
+ return matches;
409
+ }
410
+
285
411
  /** Up/Down: step within the active column; leaving a sibling column's top exits the strip. */
286
412
  #moveVertical(delta: -1 | 1): void {
287
413
  if (this.#activeVariant > 0) {
@@ -337,6 +463,7 @@ export class RewindSelectorComponent implements Component {
337
463
  const children = this.#builder.container.children;
338
464
  const prepared = this.#browser.prepareOutline(children, this.#targets, this.#selected, contentWidth);
339
465
  this.#mainVisible = prepared.visible;
466
+ this.#mainRows = prepared.childRows;
340
467
  if (prepared.selected !== this.#selected) {
341
468
  // The current target collapsed (e.g. expansion toggle): rest on the
342
469
  // nearest visible one and leave the strip.
@@ -345,6 +472,8 @@ export class RewindSelectorComponent implements Component {
345
472
  this.#siblingSelected = 0;
346
473
  }
347
474
 
475
+ if (this.#filter !== undefined) return this.#filterFrame(this.#filter, prepared.childRows, contentWidth);
476
+
348
477
  const columns = this.#stripColumns();
349
478
  const composed =
350
479
  columns.length > 0
@@ -359,14 +488,59 @@ export class RewindSelectorComponent implements Component {
359
488
  const position = this.#targets.length > 0 ? `${this.#selected + 1}/${this.#targets.length} ` : "";
360
489
  const lateral = columns.length > 0 ? "←/→ branches" : "←/→ user turns";
361
490
  return {
362
- header: [
363
- `${theme.icon.rewind} ${theme.bold("Rewind")}${theme.sep.dot}${theme.fg("dim", "pick the point to continue from")}`,
364
- ],
491
+ header: [this.#header()],
365
492
  body: {
366
493
  lines: composed.lines,
367
494
  anchor: this.#outlineAnchor(composed),
368
495
  },
369
- footer: [theme.fg("dim", `${position}↑/↓ step ${lateral} enter rewind ctrl+o expand esc cancel`)],
496
+ footer: [
497
+ theme.fg("dim", `${position}↑/↓ step ${lateral} f filter enter rewind ctrl+o expand esc cancel`),
498
+ ],
499
+ };
500
+ }
501
+
502
+ #header(): string {
503
+ return `${theme.icon.rewind} ${theme.bold("Rewind")}${theme.sep.dot}${theme.fg("dim", "pick the point to continue from")}`;
504
+ }
505
+
506
+ /** Only the matching items of the current path, concatenated; no branch strip. */
507
+ #filterFrame(
508
+ query: string,
509
+ childRows: readonly (readonly string[])[],
510
+ contentWidth: number,
511
+ ): TranscriptBrowserFrame {
512
+ const matches = this.#filterMatches();
513
+ const rows: (readonly string[])[] = [];
514
+ const targets: OutlineTarget[] = [];
515
+ for (const index of matches) {
516
+ const target = this.#targets[index]!;
517
+ const start = rows.length;
518
+ rows.push(...childRows.slice(target.start, target.end));
519
+ targets.push({ ...target, start, end: rows.length });
520
+ }
521
+ const selected = matches.indexOf(this.#selected);
522
+ const composed = composeOutlineColumn(rows, 0, rows.length, targets, selected, contentWidth, undefined);
523
+ const lines = matches.length > 0 ? composed.lines : [theme.fg("muted", ` No items match "${query}"`)];
524
+ const count =
525
+ matches.length === 0
526
+ ? theme.fg("error", "no matches")
527
+ : theme.fg("dim", `${selected >= 0 ? selected + 1 : "-"}/${matches.length}`);
528
+ return {
529
+ header: [this.#header()],
530
+ body: {
531
+ lines,
532
+ anchor:
533
+ composed.selStart >= 0
534
+ ? {
535
+ id: `rewind:filter:${query}:${this.#targets[this.#selected]?.turnId ?? this.#selected}`,
536
+ start: composed.selStart,
537
+ end: composed.selEnd,
538
+ }
539
+ : undefined,
540
+ },
541
+ footer: [
542
+ `${theme.fg("accent", "filter:")} ${query}${theme.fg("accent", "▏")} ${count} ${theme.fg("dim", "↑/↓ step ←/→ user turns enter rewind esc show all")}`,
543
+ ],
370
544
  };
371
545
  }
372
546
 
@@ -53,7 +53,7 @@ export interface StatusLineSession {
53
53
  getContextUsage(): { tokens: number; contextWindow: number; percent: number | null } | undefined;
54
54
  autoResolvedThinkingLevel(): string | undefined;
55
55
  isFastModeActive(): boolean;
56
- /** Anthropic subscription slow-mode label (`low priority until 14:30 · 62% left`) while active. */
56
+ /** Anthropic usage-limit label (`limit reached · wrapping up · resets 14:30`, `low priority until 14:30 · 62% left`). */
57
57
  getAnthropicSlowModeLabel?(): string | undefined;
58
58
  getPrewalkState?(): unknown;
59
59
  getAsyncJobSnapshot(): { running: readonly { type: string; agentId?: string }[] } | null | undefined;
@@ -293,8 +293,8 @@ const modelSegment: StatusLineSegment = {
293
293
  content += accentFg(ctx, "statusLineModel", tail);
294
294
  }
295
295
 
296
- // Anthropic slow mode: a warning-colored badge so a throttled lane is never
297
- // mistaken for normal service.
296
+ // Anthropic usage-limit stage (wrap-up allowance or low priority): a
297
+ // warning-colored badge so past-the-limit service is never mistaken for normal.
298
298
  const slowModeLabel = ctx.session.getAnthropicSlowModeLabel?.();
299
299
  if (slowModeLabel) content += theme.fg("warning", `${theme.sep.dot}${slowModeLabel}`);
300
300
  return { content, visible: true };