@oh-my-pi/pi-tui 18.2.10 → 18.3.0

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 (69) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/THIRD-PARTY-NOTICES.txt +1 -1
  3. package/dist/types/apps/ps-data.d.ts +1 -1
  4. package/dist/types/apps/ps-top.d.ts +1 -1
  5. package/dist/types/chat/chat-transcript-builder.d.ts +1 -1
  6. package/dist/types/overlays/annotation-overlay.d.ts +30 -0
  7. package/dist/types/overlays/annotation-types.d.ts +84 -0
  8. package/dist/types/overlays/copy-selector.d.ts +30 -2
  9. package/dist/types/overlays/copy-targets.d.ts +2 -0
  10. package/dist/types/overlays/model-hub.d.ts +2 -2
  11. package/dist/types/overlays/oauth-selector.d.ts +3 -6
  12. package/dist/types/overlays/usage-dashboard.d.ts +2 -0
  13. package/dist/types/prompt/image-source.d.ts +9 -8
  14. package/dist/types/render/render-utils.d.ts +2 -0
  15. package/dist/types/status-line/context-usage.d.ts +3 -1
  16. package/dist/types/theme/mermaid-cache.d.ts +4 -6
  17. package/dist/types/tools/bash.d.ts +7 -0
  18. package/dist/types/tools/daemon.d.ts +46 -0
  19. package/dist/types/tools/irc.d.ts +30 -0
  20. package/dist/types/tools/line-ranges.d.ts +1 -1
  21. package/dist/types/tools/proc-render.d.ts +34 -0
  22. package/dist/types/tools/read.d.ts +4 -1
  23. package/dist/types/tools/task.d.ts +4 -0
  24. package/dist/types/tools/wait.d.ts +131 -0
  25. package/dist/types/tools/write.d.ts +4 -0
  26. package/dist/types/tools/xd-url.d.ts +8 -0
  27. package/dist/types/windows-altgr.d.ts +54 -0
  28. package/package.json +13 -10
  29. package/src/apps/debug/protocol-probe.ts +1 -1
  30. package/src/apps/ps-data.ts +1 -1
  31. package/src/apps/ps-top.ts +1 -1
  32. package/src/chat/chat-transcript-builder.ts +4 -4
  33. package/src/chat/tool-execution.ts +8 -8
  34. package/src/chat/transcript-render-helpers.ts +3 -3
  35. package/src/components/markdown.ts +1 -1
  36. package/src/desktop-notify.ts +1 -1
  37. package/src/overlays/annotation-overlay.ts +1145 -0
  38. package/src/overlays/annotation-types.ts +78 -0
  39. package/src/overlays/copy-selector.ts +45 -19
  40. package/src/overlays/copy-targets.ts +3 -3
  41. package/src/overlays/model-hub.ts +5 -4
  42. package/src/overlays/oauth-selector.ts +8 -12
  43. package/src/overlays/plan-review-overlay.ts +213 -40
  44. package/src/overlays/settings-defs.ts +1 -0
  45. package/src/overlays/usage-dashboard.ts +120 -25
  46. package/src/prompt/image-source.ts +9 -8
  47. package/src/prompt/welcome.ts +4 -1
  48. package/src/render/render-utils.ts +1 -1
  49. package/src/setup/scenes/sign-in.ts +1 -1
  50. package/src/status-line/component.ts +15 -4
  51. package/src/status-line/context-usage.ts +15 -3
  52. package/src/status-line/segments.ts +3 -3
  53. package/src/terminal-capabilities.ts +16 -7
  54. package/src/terminal.ts +8 -1
  55. package/src/theme/mermaid-cache.ts +83 -25
  56. package/src/tools/bash.ts +14 -1
  57. package/src/tools/daemon.ts +51 -0
  58. package/src/tools/index.ts +2 -2
  59. package/src/tools/irc.ts +33 -0
  60. package/src/tools/line-ranges.ts +13 -3
  61. package/src/tools/proc-render.ts +294 -0
  62. package/src/tools/read.ts +22 -1
  63. package/src/tools/task.ts +18 -0
  64. package/src/tools/wait.ts +595 -0
  65. package/src/tools/write.ts +76 -0
  66. package/src/tools/xd-url.ts +9 -0
  67. package/src/windows-altgr.ts +227 -0
  68. package/dist/types/tools/hub.d.ts +0 -342
  69. package/src/tools/hub.ts +0 -1492
@@ -19,7 +19,7 @@
19
19
  import {
20
20
  type Component,
21
21
  Ellipsis,
22
- Input,
22
+ Editor,
23
23
  Markdown,
24
24
  type MarkdownTheme,
25
25
  matchesKey,
@@ -31,7 +31,7 @@ import {
31
31
  } from "../index";
32
32
  import { sanitizeText } from "@oh-my-pi/pi-utils";
33
33
  import { sanitizeStatusText } from "../chrome/shared";
34
- import { getMarkdownTheme, theme } from "../theme/theme";
34
+ import { getEditorTheme, getMarkdownTheme, theme } from "../theme/theme";
35
35
  import {
36
36
  matchesAppExternalEditor,
37
37
  matchesSelectCancel,
@@ -57,6 +57,8 @@ import { renderSegmentTrack } from "../chrome/segment-track";
57
57
  const OVERLAY_TITLE = "Plan Review";
58
58
  /** Minimum plan-body rows kept visible even on short terminals. */
59
59
  const MIN_BODY_ROWS = 3;
60
+ /** Visible rows for the in-overlay annotation editor before it scrolls. */
61
+ const MAX_ANNOTATION_EDITOR_ROWS = 6;
60
62
  /** Sidebar gates: enough headings, a wide terminal, and a usable body column. */
61
63
  const SIDEBAR_MIN_HEADINGS = 2;
62
64
  const SIDEBAR_MIN_TOTAL_WIDTH = 64;
@@ -93,6 +95,16 @@ interface BodyRowAnchor {
93
95
  contextTruncated: boolean;
94
96
  }
95
97
 
98
+ interface AnnotationSelection {
99
+ sectionIndex: number;
100
+ annotationIndex: number;
101
+ }
102
+
103
+ interface AnnotationChooser {
104
+ entries: AnnotationSelection[];
105
+ selected: number;
106
+ }
107
+
96
108
  /** Serializable annotations retained by the plan-review owner between overlays. */
97
109
  export interface PlanReviewAnnotationState {
98
110
  annotations: Array<{
@@ -207,8 +219,10 @@ export class PlanReviewOverlay implements Component {
207
219
  /** Label of the committed choice, shown while the async approval settles. */
208
220
  #committedLabel: string | undefined;
209
221
  #annotating = false;
210
- #input: Input;
222
+ #editor: Editor;
211
223
  #annotationTarget: BodyRowAnchor | { sectionIndex: number; row: null; context: null } | undefined;
224
+ #editingAnnotation: AnnotationSelection | undefined;
225
+ #annotationChooser: AnnotationChooser | undefined;
212
226
 
213
227
  constructor(
214
228
  planContent: string,
@@ -236,10 +250,11 @@ export class PlanReviewOverlay implements Component {
236
250
  } else {
237
251
  this.#sliderIndex = 0;
238
252
  }
239
- this.#input = new Input();
240
- this.#input.setUseTerminalCursor(false);
241
- this.#input.onSubmit = value => this.#submitAnnotation(value);
242
- this.#input.onEscape = () => this.#exitAnnotate();
253
+ this.#editor = new Editor(getEditorTheme());
254
+ this.#editor.setBorderVisible(false);
255
+ this.#editor.setPromptGutter("> ");
256
+ this.#editor.setUseTerminalCursor(false);
257
+ this.#editor.onSubmit = value => this.#submitAnnotation(value);
243
258
  this.#setSections(planContent);
244
259
  this.#restoreAnnotationState(options.annotationState);
245
260
  if (Array.isArray(options.annotationState?.annotations) && options.annotationState.annotations.length > 0) {
@@ -490,14 +505,22 @@ export class PlanReviewOverlay implements Component {
490
505
  handleInput(keyData: string): void {
491
506
  if (this.#committed) return;
492
507
  if (keyData.startsWith("\x1b[<") && this.#handleMouse(keyData)) return;
508
+ if (this.#annotationChooser) {
509
+ this.#handleAnnotationChooser(keyData);
510
+ return;
511
+ }
493
512
  if (this.#annotating) {
494
513
  if (this.callbacks.onAnnotationExternalEditor && matchesAppExternalEditor(keyData)) {
495
- this.callbacks.onAnnotationExternalEditor(this.#input.getValue(), text => {
496
- if (text !== null) this.#submitAnnotation(text);
514
+ this.callbacks.onAnnotationExternalEditor(this.#editor.getExpandedText(), text => {
515
+ if (text !== null && this.#annotating) this.#editor.setText(text);
497
516
  });
498
517
  return;
499
518
  }
500
- this.#input.handleInput(keyData);
519
+ if (matchesSelectCancel(keyData)) {
520
+ this.#exitAnnotate();
521
+ return;
522
+ }
523
+ this.#editor.handleInput(keyData);
501
524
  return;
502
525
  }
503
526
  if (matchesSelectCancel(keyData)) {
@@ -505,6 +528,14 @@ export class PlanReviewOverlay implements Component {
505
528
  this.callbacks.onCancel();
506
529
  return;
507
530
  }
531
+ if (this.#focus !== "actions" && keyData === "u") {
532
+ this.#undoLast();
533
+ return;
534
+ }
535
+ if (this.#focus !== "actions" && keyData === "e") {
536
+ this.#editCurrentAnnotation();
537
+ return;
538
+ }
508
539
  if (this.callbacks.onExternalEditor && matchesAppExternalEditor(keyData)) {
509
540
  this.callbacks.onExternalEditor();
510
541
  return;
@@ -726,10 +757,6 @@ export class PlanReviewOverlay implements Component {
726
757
  this.#startSectionAnnotate();
727
758
  return;
728
759
  }
729
- if (data === "u") {
730
- this.#undoLast();
731
- return;
732
- }
733
760
  }
734
761
 
735
762
  #moveTocCursor(delta: number): void {
@@ -828,39 +855,145 @@ export class PlanReviewOverlay implements Component {
828
855
 
829
856
  #startAnnotate(target: BodyRowAnchor | { sectionIndex: number; row: null; context: null }): void {
830
857
  this.#annotationTarget = target;
858
+ this.#editingAnnotation = undefined;
859
+ this.#annotating = true;
860
+ this.#editor.setText("");
861
+ }
862
+
863
+ #startExistingAnnotation(selection: AnnotationSelection): void {
864
+ const section = this.#sections[selection.sectionIndex];
865
+ const annotation = section?.annotations[selection.annotationIndex];
866
+ if (!section || !annotation) return;
867
+ this.#annotationChooser = undefined;
868
+ this.#editingAnnotation = selection;
869
+ this.#annotationTarget =
870
+ annotation.target.kind === "section"
871
+ ? { sectionIndex: selection.sectionIndex, row: null, context: null }
872
+ : {
873
+ sectionIndex: selection.sectionIndex,
874
+ row: annotation.target.row,
875
+ context: annotation.target.context,
876
+ contextTruncated: annotation.target.contextTruncated,
877
+ };
831
878
  this.#annotating = true;
832
- this.#input.setValue("");
879
+ this.#editor.setText(annotation.note);
880
+ }
881
+
882
+ #annotationMatchesBodyAnchor(annotation: OverlayAnnotation, anchor: BodyRowAnchor): boolean {
883
+ if (annotation.target.kind === "section") return true;
884
+ if (annotation.target.row === anchor.row && annotation.target.context === anchor.context) return true;
885
+ const seenRows = new Set<number>();
886
+ const sectionAnchors: LineAnchorContext[] = [];
887
+ for (const candidate of this.#bodyRowAnchors) {
888
+ if (candidate.sectionIndex !== anchor.sectionIndex || seenRows.has(candidate.row)) continue;
889
+ seenRows.add(candidate.row);
890
+ sectionAnchors.push({ text: candidate.context, truncated: candidate.contextTruncated });
891
+ }
892
+ const resolvedRow = this.#resolveLineRow(
893
+ annotation.target.row,
894
+ { text: annotation.target.context, truncated: annotation.target.contextTruncated },
895
+ sectionAnchors,
896
+ );
897
+ return resolvedRow === anchor.row;
898
+ }
899
+
900
+ #annotationCandidates(): AnnotationSelection[] {
901
+ if (this.#focus === "toc") {
902
+ const sectionIndex = this.#toc[this.#tocCursor];
903
+ const section = sectionIndex === undefined ? undefined : this.#sections[sectionIndex];
904
+ if (!section || section.annotations.length === 0) return [];
905
+ return section.annotations.map((_, annotationIndex) => ({ sectionIndex, annotationIndex }));
906
+ }
907
+ if (this.#focus !== "body") return [];
908
+ const maxRow = this.#bodyRowAnchors.length - 1;
909
+ if (maxRow < 0) return [];
910
+ const topRow = Math.max(0, Math.min(maxRow, Math.floor(this.#scrollView.getScrollOffset())));
911
+ const anchor = this.#bodyRowAnchors[topRow];
912
+ const section = anchor ? this.#sections[anchor.sectionIndex] : undefined;
913
+ if (!anchor || !section) return [];
914
+ const entries: AnnotationSelection[] = [];
915
+ for (const [annotationIndex, annotation] of section.annotations.entries()) {
916
+ if (this.#annotationMatchesBodyAnchor(annotation, anchor)) {
917
+ entries.push({ sectionIndex: anchor.sectionIndex, annotationIndex });
918
+ }
919
+ }
920
+ return entries;
921
+ }
922
+
923
+ #editCurrentAnnotation(): void {
924
+ const entries = this.#annotationCandidates();
925
+ if (entries.length === 0) return;
926
+ if (entries.length === 1) {
927
+ this.#startExistingAnnotation(entries[0]!);
928
+ return;
929
+ }
930
+ this.#annotationChooser = { entries, selected: 0 };
931
+ }
932
+
933
+ #handleAnnotationChooser(data: string): void {
934
+ const chooser = this.#annotationChooser;
935
+ if (!chooser) return;
936
+ if (matchesSelectCancel(data)) {
937
+ this.#annotationChooser = undefined;
938
+ return;
939
+ }
940
+ if (matchesSelectUp(data) || matchesKey(data, "k")) {
941
+ chooser.selected = (chooser.selected - 1 + chooser.entries.length) % chooser.entries.length;
942
+ return;
943
+ }
944
+ if (matchesSelectDown(data) || matchesKey(data, "j")) {
945
+ chooser.selected = (chooser.selected + 1) % chooser.entries.length;
946
+ return;
947
+ }
948
+ if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
949
+ const selection = chooser.entries[chooser.selected];
950
+ this.#annotationChooser = undefined;
951
+ if (selection) this.#startExistingAnnotation(selection);
952
+ }
833
953
  }
834
954
 
835
955
  #submitAnnotation(value: string): void {
836
- this.#annotating = false;
837
956
  const note = value.trim();
838
957
  const target = this.#annotationTarget;
958
+ const editing = this.#editingAnnotation;
959
+ this.#annotating = false;
839
960
  this.#annotationTarget = undefined;
840
- const section = target ? this.#sections[target.sectionIndex] : undefined;
841
- if (note && section && target) {
961
+ this.#editingAnnotation = undefined;
962
+ this.#editor.setText("");
963
+ if (editing) {
964
+ const section = this.#sections[editing.sectionIndex];
965
+ const annotation = section?.annotations[editing.annotationIndex];
966
+ if (!section || !annotation) return;
842
967
  this.#pushUndo();
843
- section.annotations.push({
844
- note,
845
- target:
846
- target.row === null
847
- ? { kind: "section" }
848
- : {
849
- kind: "line",
850
- row: target.row,
851
- context: target.context,
852
- contextTruncated: target.contextTruncated,
853
- },
854
- });
968
+ if (note) annotation.note = note;
969
+ else section.annotations.splice(editing.annotationIndex, 1);
855
970
  this.#recomputeFeedback();
971
+ return;
856
972
  }
857
- this.#input.setValue("");
973
+ if (!note || !target) return;
974
+ const section = this.#sections[target.sectionIndex];
975
+ if (!section) return;
976
+ this.#pushUndo();
977
+ section.annotations.push({
978
+ note,
979
+ target:
980
+ target.row === null
981
+ ? { kind: "section" }
982
+ : {
983
+ kind: "line",
984
+ row: target.row,
985
+ context: target.context,
986
+ contextTruncated: target.contextTruncated,
987
+ },
988
+ });
989
+ this.#recomputeFeedback();
858
990
  }
859
991
 
860
992
  #exitAnnotate(): void {
861
993
  this.#annotating = false;
862
994
  this.#annotationTarget = undefined;
863
- this.#input.setValue("");
995
+ this.#editingAnnotation = undefined;
996
+ this.#editor.setText("");
864
997
  }
865
998
 
866
999
  #recomputeFeedback(): void {
@@ -942,10 +1075,10 @@ export class PlanReviewOverlay implements Component {
942
1075
  if (this.#slider) parts.push("◂▸ model");
943
1076
  break;
944
1077
  case "toc":
945
- parts.push("↑↓ section", "⏎ open", "a annotate", "d delete", "u undo");
1078
+ parts.push("↑↓ section", "⏎ open", "a annotate", "e edit", "d delete", "u undo");
946
1079
  break;
947
1080
  case "body":
948
- parts.push("↑↓ scroll", "⇧ faster", "pgup/pgdn", "g/G ends", "a annotate");
1081
+ parts.push("↑↓ scroll", "⇧ faster", "pgup/pgdn", "g/G ends", "a annotate", "e edit", "u undo");
949
1082
  break;
950
1083
  }
951
1084
  if (this.callbacks.onCopyPlan) parts.push("c copy");
@@ -1136,8 +1269,39 @@ export class PlanReviewOverlay implements Component {
1136
1269
  if (glow) return theme.fg("accent", line);
1137
1270
  return theme.fg("muted", line);
1138
1271
  }
1272
+ #renderAnnotationChooserLines(innerWidth: number, maxRows = Number.POSITIVE_INFINITY): string[] {
1273
+ const chooser = this.#annotationChooser;
1274
+ if (!chooser || maxRows <= 0) return [];
1275
+ const optionLimit = Number.isFinite(maxRows) ? Math.max(0, Math.floor(maxRows) - 2) : chooser.entries.length;
1276
+ const start =
1277
+ optionLimit >= chooser.entries.length
1278
+ ? 0
1279
+ : Math.max(
1280
+ 0,
1281
+ Math.min(chooser.selected - Math.floor(optionLimit / 2), chooser.entries.length - optionLimit),
1282
+ );
1283
+ const lines = [theme.bold(theme.fg("accent", "Edit annotation"))];
1284
+ for (let windowIndex = 0; windowIndex < optionLimit; windowIndex++) {
1285
+ const optionIndex = start + windowIndex;
1286
+ const selection = chooser.entries[optionIndex];
1287
+ if (!selection) continue;
1288
+ const section = this.#sections[selection.sectionIndex];
1289
+ const annotation = section?.annotations[selection.annotationIndex];
1290
+ if (!section || !annotation) continue;
1291
+ const target = annotation.target.kind === "section" ? "section" : `line ${annotation.target.context}`;
1292
+ const note = sanitizeStatusText(annotation.note);
1293
+ const marker = optionIndex === chooser.selected ? theme.fg("accent", "› ") : " ";
1294
+ const label = `${marker}${sanitizeStatusText(section.title || "Plan preamble")} · ${target} · ${note}`;
1295
+ lines.push(truncateToWidth(label, innerWidth, Ellipsis.Unicode));
1296
+ }
1297
+ if (!Number.isFinite(maxRows) || maxRows >= 2) {
1298
+ lines.push(theme.fg("dim", "↑↓ choose · enter edit · esc cancel"));
1299
+ }
1300
+ return lines.slice(0, Math.max(0, Math.floor(maxRows)));
1301
+ }
1139
1302
 
1140
- #renderFooterLines(innerWidth: number): string[] {
1303
+ #renderFooterLines(innerWidth: number, maxChooserRows = Number.POSITIVE_INFINITY): string[] {
1304
+ if (this.#annotationChooser) return this.#renderAnnotationChooserLines(innerWidth, maxChooserRows);
1141
1305
  if (this.#annotating) {
1142
1306
  const target = this.#annotationTarget;
1143
1307
  const section = target ? this.#sections[target.sectionIndex] : undefined;
@@ -1151,9 +1315,12 @@ export class PlanReviewOverlay implements Component {
1151
1315
  innerWidth,
1152
1316
  Ellipsis.Unicode,
1153
1317
  );
1154
- const hintParts = ["enter save", "esc cancel"];
1318
+ const hintParts = ["enter save", "shift+enter newline", "esc cancel"];
1319
+ if (this.#editingAnnotation) hintParts.push("empty deletes");
1155
1320
  if (this.#externalEditorLabel) hintParts.push(`${this.#externalEditorLabel} editor`);
1156
- return [caption, this.#input.render(innerWidth)[0] ?? "", theme.fg("dim", hintParts.join(" · "))];
1321
+ this.#editor.setMaxHeight(Math.max(1, Math.min(MAX_ANNOTATION_EDITOR_ROWS, (process.stdout.rows || 40) - 12)));
1322
+ this.#editor.focused = true;
1323
+ return [caption, ...this.#editor.render(innerWidth), theme.fg("dim", hintParts.join(" · "))];
1157
1324
  }
1158
1325
  return [theme.fg("dim", this.#buildHelp())];
1159
1326
  }
@@ -1171,14 +1338,20 @@ export class PlanReviewOverlay implements Component {
1171
1338
  const submittingLabel = this.#committedLabel ? `${this.#committedLabel} — submitting…` : "Submitting…";
1172
1339
  const optionLines = committed ? [theme.bold(theme.fg("accent", submittingLabel))] : this.#renderOptionLines();
1173
1340
  const promptLines = this.#promptTitle ? [theme.bold(theme.fg("accent", this.#promptTitle))] : [];
1341
+ const baseChrome = 4 + promptLines.length + sliderLines.length + optionLines.length;
1342
+ const chooserFooterRows = this.#annotationChooser
1343
+ ? Math.max(0, termHeight - baseChrome - MIN_BODY_ROWS)
1344
+ : Number.POSITIVE_INFINITY;
1174
1345
  const footerLines = committed
1175
1346
  ? [theme.fg("dim", "Applying your selection — this can take a moment while context is prepared.")]
1176
- : this.#renderFooterLines(innerWidth);
1347
+ : this.#renderFooterLines(innerWidth, chooserFooterRows);
1177
1348
 
1178
1349
  // Chrome rows: top border, two dividers, bottom border, plus the
1179
1350
  // prompt/slider/option/footer rows between them.
1180
- const chrome = 4 + promptLines.length + sliderLines.length + optionLines.length + footerLines.length;
1181
- const regionRows = Math.max(MIN_BODY_ROWS, termHeight - chrome);
1351
+ const chrome = baseChrome + footerLines.length;
1352
+ const regionRows = this.#annotationChooser
1353
+ ? Math.max(0, termHeight - chrome)
1354
+ : Math.max(MIN_BODY_ROWS, termHeight - chrome);
1182
1355
 
1183
1356
  const bodyLines = this.#buildBody(bodyContentWidth);
1184
1357
  this.#layoutBody(bodyLines, regionRows);
@@ -64,6 +64,7 @@ export const TAB_GROUPS: Record<SettingTab, readonly string[]> = {
64
64
  "Power",
65
65
  "Agent",
66
66
  "Git",
67
+ "Skills",
67
68
  ],
68
69
  context: ["General", "Compaction", "Rules (TTSR)", "Experimental"],
69
70
  memory: ["General", "Auto-Learn", "Mnemopi", "Hindsight", "Sharpshooter"],
@@ -7,7 +7,16 @@
7
7
  */
8
8
  import * as os from "node:os";
9
9
  import { resolveUsedFraction, type UsageLimit, type UsageReport } from "@oh-my-pi/pi-ai";
10
- import { type Component, matchesKey, replaceTabs, routeSgrMouseInput, truncateToWidth, visibleWidth } from "../index";
10
+ import {
11
+ type Component,
12
+ matchesKey,
13
+ replaceTabs,
14
+ routeSgrMouseInput,
15
+ sliceWithWidth,
16
+ truncateToWidth,
17
+ visibleWidth,
18
+ wrapTextWithAnsi,
19
+ } from "../index";
11
20
  import { colorLuma, formatDuration, hexToRgb, rgbToHex, sanitizeText } from "@oh-my-pi/pi-utils";
12
21
  import { formatProviderName } from "../chrome/format";
13
22
  import {
@@ -19,6 +28,8 @@ import {
19
28
  import { colorToAnsi } from "../theme/color";
20
29
  import { ensureThemeSync, theme } from "../theme/theme";
21
30
  import { formatAbsoluteOnlyAmount } from "../prompt/usage-amounts";
31
+ import { truncateMiddleToWidth } from "../render/render-utils";
32
+ import { sanitizeDisplayLine } from "./extensions/display-text";
22
33
  import {
23
34
  matchesSelectCancel,
24
35
  matchesSelectDown,
@@ -72,6 +83,8 @@ export interface ProviderCard {
72
83
  soonestExpiryMs?: number;
73
84
  unavailableReasons: string[];
74
85
  };
86
+ /** Labels of accounts with verified Daybreak access. */
87
+ daybreakAccounts?: string[];
75
88
  }
76
89
 
77
90
  /**
@@ -191,6 +204,17 @@ export function buildProviderCards(reports: UsageReport[], nowMs: number): Provi
191
204
  unavailableReasons,
192
205
  }
193
206
  : undefined;
207
+ const daybreakAccounts = providerReports.flatMap((report, index) =>
208
+ report.metadata?.daybreak === true
209
+ ? [
210
+ typeof report.metadata.email === "string" && report.metadata.email
211
+ ? report.metadata.email
212
+ : typeof report.metadata.accountId === "string" && report.metadata.accountId
213
+ ? report.metadata.accountId
214
+ : `account ${index + 1}`,
215
+ ]
216
+ : [],
217
+ );
194
218
  cards.push({
195
219
  provider,
196
220
  name: formatProviderName(provider),
@@ -198,8 +222,11 @@ export function buildProviderCards(reports: UsageReport[], nowMs: number): Provi
198
222
  windows,
199
223
  unlimited: windows.length === 0,
200
224
  idle:
201
- !resetCredits && windows.every(window => window.fraction !== undefined && window.fraction < IDLE_FRACTION),
225
+ !resetCredits &&
226
+ daybreakAccounts.length === 0 &&
227
+ windows.every(window => window.fraction !== undefined && window.fraction < IDLE_FRACTION),
202
228
  resetCredits,
229
+ ...(daybreakAccounts.length > 0 ? { daybreakAccounts } : {}),
203
230
  });
204
231
  }
205
232
 
@@ -333,6 +360,16 @@ export function formatActivityErrorDetail(error: string, homeDir = os.homedir())
333
360
  const CARD_MIN_WIDTH = 32;
334
361
  const CARD_GUTTER = 3;
335
362
  const CARD_MAX_WINDOWS = 4;
363
+ const CARD_MIN_BAR_WIDTH = 12;
364
+ const CARD_MAX_LABEL_LINES = 2;
365
+
366
+ interface CardRowLayout {
367
+ labelWidth: number;
368
+ resetWidth: number;
369
+ barWidth: number;
370
+ stacked: boolean;
371
+ labelHeights: number[];
372
+ }
336
373
 
337
374
  export class UsageDashboardComponent implements Component {
338
375
  #options: UsageDashboardOptions;
@@ -423,7 +460,7 @@ export class UsageDashboardComponent implements Component {
423
460
  return `${theme.fg(this.#statusColor(status), bar)}${theme.fg("dim", empty)}`;
424
461
  }
425
462
 
426
- #renderCardLines(card: ProviderCard, width: number): string[] {
463
+ #renderCardLines(card: ProviderCard, width: number, labels: string[][], layout: CardRowLayout): string[] {
427
464
  const lines: string[] = [];
428
465
  const cardStatus = card.unlimited ? "ok" : aggregateStatus(card.windows);
429
466
  const accountsText = card.accounts > 1 ? theme.fg("dim", `${card.accounts} accts`) : "";
@@ -432,6 +469,11 @@ export class UsageDashboardComponent implements Component {
432
469
  const titlePad = Math.max(0, width - 2 - visibleWidth(title) - visibleWidth(accountsText));
433
470
  lines.push(`${this.#statusIcon(cardStatus)} ${title}${" ".repeat(titlePad)}${accountsText}`);
434
471
 
472
+ for (const account of card.daybreakAccounts ?? []) {
473
+ const label = sanitizeText(account.replace(/[\r\n\t]+/g, " "));
474
+ lines.push(` ${theme.fg("success", truncateToWidth(`daybreak · ${label}`, width - 2))}`);
475
+ }
476
+
435
477
  if (card.resetCredits) {
436
478
  const resets = card.resetCredits;
437
479
  let resetText = `✦ ${resets.bankedCount} reset${resets.bankedCount === 1 ? "" : "s"}`;
@@ -457,35 +499,33 @@ export class UsageDashboardComponent implements Component {
457
499
  }
458
500
 
459
501
  const hidden = card.windows.length - CARD_MAX_WINDOWS;
460
- const visibleWindows = card.windows.slice(0, CARD_MAX_WINDOWS);
461
- // Fixed columns across every row of the card so bars all start and end
462
- // at the same x: label | bar | pct | reset. The reset column sizes to
463
- // the card's widest countdown instead of flexing per row.
464
- const resetWidth = visibleWindows.reduce(
465
- (max, window) => Math.max(max, window.resetMs !== undefined ? formatDuration(window.resetMs).length : 0),
466
- 0,
467
- );
468
- const labelWidth = Math.min(16, Math.max(6, width - 24));
469
- const barWidth = Math.max(5, width - 2 - labelWidth - 1 - 5 - (resetWidth > 0 ? resetWidth + 1 : 0));
470
- for (const window of visibleWindows) {
471
- const tagPlain = window.windowTag
472
- ? truncateToWidth(window.windowTag, Math.max(2, Math.floor(labelWidth / 2) - 1))
473
- : "";
474
- const baseWidth = tagPlain ? labelWidth - visibleWidth(tagPlain) - 1 : labelWidth;
475
- const basePlain = truncateToWidth(window.label, baseWidth).padEnd(baseWidth);
476
- const label = tagPlain
477
- ? `${theme.fg("muted", basePlain)} ${theme.fg("dim", tagPlain)}`
478
- : theme.fg("muted", basePlain);
502
+ const { labelWidth, resetWidth, barWidth, stacked, labelHeights } = layout;
503
+ const contentWidth = Math.max(1, width - 2);
504
+ for (let index = 0; index < Math.min(card.windows.length, CARD_MAX_WINDOWS); index++) {
505
+ const window = card.windows[index]!;
506
+ const labelLines = labels[index]!;
507
+ const label = labelLines[0] ?? "";
508
+ const prefix = stacked ? "" : `${label}${" ".repeat(labelWidth - visibleWidth(label))} `;
509
+ if (stacked) {
510
+ for (let line = 0; line < labelHeights[index]; line++) {
511
+ lines.push(` ${labelLines[line] ?? ""}`);
512
+ }
513
+ }
479
514
  if (window.fraction === undefined) {
480
515
  const text = theme.fg("dim", window.usedText ?? "no data");
481
- lines.push(truncateToWidth(` ${label} ${text}`, width));
516
+ for (const line of wrapTextWithAnsi(`${prefix}${text}`, contentWidth)) lines.push(` ${line}`);
482
517
  continue;
483
518
  }
484
519
  const freePct = Math.max(0, Math.round((1 - window.fraction) * 100));
485
520
  const pctText = theme.fg(this.#statusColor(window.status), `${freePct}%`.padStart(5));
486
521
  const resetPlain = window.resetMs !== undefined ? formatDuration(window.resetMs) : "";
487
522
  const resetText = resetWidth > 0 ? ` ${theme.fg("dim", resetPlain.padStart(resetWidth))}` : "";
488
- lines.push(` ${label} ${this.#miniBar(window.fraction, window.status, barWidth)}${pctText}${resetText}`);
523
+ for (const line of wrapTextWithAnsi(
524
+ `${prefix}${this.#miniBar(window.fraction, window.status, barWidth)}${pctText}${resetText}`,
525
+ contentWidth,
526
+ )) {
527
+ lines.push(` ${line}`);
528
+ }
489
529
  }
490
530
  if (hidden > 0) lines.push(` ${theme.fg("dim", `+${hidden} more`)}`);
491
531
  return lines;
@@ -499,7 +539,62 @@ export class UsageDashboardComponent implements Component {
499
539
  const cardWidth = Math.floor((innerWidth - (columns - 1) * CARD_GUTTER) / columns);
500
540
  const lines: string[] = [];
501
541
  for (let start = 0; start < active.length; start += columns) {
502
- const rowCards = active.slice(start, start + columns).map(card => this.#renderCardLines(card, cardWidth));
542
+ const cards = active.slice(start, start + columns);
543
+ const windows = cards.map(card => card.windows.slice(0, CARD_MAX_WINDOWS));
544
+ const labels = windows.map(rows =>
545
+ rows.map(window => {
546
+ const label = theme.fg("muted", sanitizeDisplayLine(window.label));
547
+ const tag = window.windowTag ? sanitizeDisplayLine(window.windowTag) : "";
548
+ return tag ? `${label} ${theme.fg("dim", tag)}` : label;
549
+ }),
550
+ );
551
+ // One geometry per grid row: labels, bars, and resets share columns,
552
+ // and every card stacks together when inline bars would be too short.
553
+ const labelWidth = labels.reduce(
554
+ (max, rows) => rows.reduce((width, label) => Math.max(width, visibleWidth(label)), max),
555
+ 0,
556
+ );
557
+ const resetWidth = windows.reduce(
558
+ (max, rows) =>
559
+ rows.reduce(
560
+ (width, window) =>
561
+ Math.max(width, window.resetMs !== undefined ? formatDuration(window.resetMs).length : 0),
562
+ max,
563
+ ),
564
+ 0,
565
+ );
566
+ const contentWidth = Math.max(1, cardWidth - 2);
567
+ const suffixWidth = 5 + (resetWidth > 0 ? resetWidth + 1 : 0);
568
+ const inlineBarWidth = contentWidth - labelWidth - 1 - suffixWidth;
569
+ const stacked = inlineBarWidth < CARD_MIN_BAR_WIDTH;
570
+ const labelLines = labels.map(rows =>
571
+ rows.map(label => {
572
+ if (!stacked) return [label];
573
+ // Bound wrapping work as well as height, keeping the suffix that
574
+ // distinguishes model-specific quota buckets.
575
+ const bounded = truncateMiddleToWidth(label, contentWidth * CARD_MAX_LABEL_LINES);
576
+ const lines = wrapTextWithAnsi(bounded, contentWidth);
577
+ if (lines.length <= CARD_MAX_LABEL_LINES) return lines;
578
+ // Word wrapping can waste enough cells to create a third line even
579
+ // when the label fits. Use a grapheme-safe column split in that case.
580
+ const first = sliceWithWidth(bounded, 0, contentWidth, true);
581
+ const rest = sliceWithWidth(bounded, first.width, visibleWidth(bounded) - first.width, true);
582
+ return [first.text, truncateMiddleToWidth(rest.text, contentWidth)];
583
+ }),
584
+ );
585
+ const labelHeights = Array.from({ length: CARD_MAX_WINDOWS }, (_, index) =>
586
+ Math.max(0, ...labelLines.map(rows => rows[index]?.length ?? 0)),
587
+ );
588
+ const layout: CardRowLayout = {
589
+ labelWidth,
590
+ resetWidth,
591
+ barWidth: Math.max(1, stacked ? contentWidth - suffixWidth : inlineBarWidth),
592
+ stacked,
593
+ labelHeights,
594
+ };
595
+ const rowCards = cards.map((card, index) =>
596
+ this.#renderCardLines(card, cardWidth, labelLines[index]!, layout),
597
+ );
503
598
  const height = Math.max(...rowCards.map(card => card.length));
504
599
  for (let lineIdx = 0; lineIdx < height; lineIdx++) {
505
600
  const segments = rowCards.map(card => {
@@ -1,10 +1,10 @@
1
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).
2
+ * File source carried on image attachments backed by a file on disk:
3
+ * path-pasted/drag-and-dropped images, clipboard images the coding agent commits
4
+ * to the session's `local://` root, and generated video contact-sheet previews.
5
+ * Symbol metadata stays out of serialized/model-bound image data while traveling
6
+ * with the draft object, until AgentSession creates the hidden companion message
7
+ * that tells the model the path (and link materialization prefers it over a blob copy).
8
8
  */
9
9
  import type { ImageContent } from "@oh-my-pi/pi-ai";
10
10
  import { isRecord } from "@oh-my-pi/pi-utils";
@@ -12,8 +12,9 @@ import { isRecord } from "@oh-my-pi/pi-utils";
12
12
  /** How an image attachment's backing file entered the session. */
13
13
  export type ImageAttachmentSourceKind = "image" | "video";
14
14
 
15
- /** Original local file backing an image attachment. */
15
+ /** Local file backing an image attachment. */
16
16
  export interface ImageAttachmentSource {
17
+ /** Absolute filesystem path, or a session-relative `local://` URL for images committed to the session. */
17
18
  readonly path: string;
18
19
  readonly kind: ImageAttachmentSourceKind;
19
20
  }
@@ -43,7 +44,7 @@ function isImageAttachmentSource(value: unknown): value is ImageAttachmentSource
43
44
  return isRecord(value) && typeof value.path === "string" && (value.kind === "image" || value.kind === "video");
44
45
  }
45
46
 
46
- /** Return the original local source of an image attachment, or undefined for payloads with no source file (clipboard bitmaps). */
47
+ /** Return the local file backing an image attachment, or undefined for payloads with no file on disk. */
47
48
  export function imageAttachmentSource(image: ImageContent): ImageAttachmentSource | undefined {
48
49
  if (!(kImageAttachmentSource in image)) return undefined;
49
50
  const source = image[kImageAttachmentSource];
@@ -185,10 +185,13 @@ export class WelcomeComponent implements Component {
185
185
  this.#requestRender();
186
186
  this.#animTimer = setInterval(() => {
187
187
  const elapsed = performance.now() - (this.#animStart ?? 0);
188
+ const requestRender = this.#requestRender;
188
189
  if (elapsed >= INTRO_MS) {
189
190
  this.#stopAnimation();
190
191
  }
191
- this.#requestRender?.();
192
+ // Stopping clears the callback, but the settled frame must still paint
193
+ // so an oversized startup header can retire into native scrollback.
194
+ requestRender?.();
192
195
  }, INTRO_TICK_MS);
193
196
  }
194
197
 
@@ -211,7 +211,7 @@ export function expandKeyHint(): string {
211
211
  // Text Truncation Utilities
212
212
  // =============================================================================
213
213
  /** Keep both ends of a single-line label without splitting wide characters. */
214
- function truncateMiddleToWidth(text: string, maxWidth: number): string {
214
+ export function truncateMiddleToWidth(text: string, maxWidth: number): string {
215
215
  const width = visibleWidth(text);
216
216
  if (width <= maxWidth) return text;
217
217
  if (maxWidth <= 1) return maxWidth === 1 ? "…" : "";