@oh-my-pi/pi-coding-agent 15.9.0 → 15.9.3

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 (124) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/types/cli/dry-balance-cli.d.ts +104 -0
  3. package/dist/types/cli/update-cli.d.ts +15 -1
  4. package/dist/types/commands/dry-balance.d.ts +31 -0
  5. package/dist/types/config/append-only-context-mode.d.ts +8 -0
  6. package/dist/types/config/model-registry.d.ts +5 -0
  7. package/dist/types/config/models-config-schema.d.ts +18 -0
  8. package/dist/types/config/settings-schema.d.ts +3 -3
  9. package/dist/types/config/settings.d.ts +11 -0
  10. package/dist/types/discovery/helpers.d.ts +1 -0
  11. package/dist/types/exa/mcp-client.d.ts +2 -1
  12. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -3
  13. package/dist/types/hindsight/bank.d.ts +17 -9
  14. package/dist/types/hindsight/mental-models.d.ts +1 -1
  15. package/dist/types/hindsight/state.d.ts +9 -3
  16. package/dist/types/mcp/json-rpc.d.ts +6 -1
  17. package/dist/types/mcp/manager.d.ts +1 -1
  18. package/dist/types/mcp/tool-bridge.d.ts +4 -0
  19. package/dist/types/mnemopi/state.d.ts +2 -2
  20. package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
  21. package/dist/types/modes/components/extensions/extension-dashboard.d.ts +1 -0
  22. package/dist/types/modes/components/plugin-settings.d.ts +40 -8
  23. package/dist/types/modes/components/session-selector.d.ts +8 -3
  24. package/dist/types/modes/components/settings-selector.d.ts +1 -1
  25. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  26. package/dist/types/modes/utils/keybinding-matchers.d.ts +4 -0
  27. package/dist/types/session/agent-session.d.ts +13 -1
  28. package/dist/types/session/auth-storage.d.ts +2 -2
  29. package/dist/types/session/history-storage.d.ts +3 -4
  30. package/dist/types/session/messages.d.ts +1 -0
  31. package/dist/types/session/session-manager.d.ts +1 -0
  32. package/dist/types/slash-commands/types.d.ts +17 -4
  33. package/dist/types/task/types.d.ts +2 -0
  34. package/dist/types/tiny/text.d.ts +17 -0
  35. package/dist/types/tools/index.d.ts +16 -0
  36. package/dist/types/tools/path-utils.d.ts +11 -0
  37. package/dist/types/web/search/providers/base.d.ts +14 -0
  38. package/dist/types/web/search/providers/exa.d.ts +9 -0
  39. package/dist/types/web/search/providers/perplexity.d.ts +2 -2
  40. package/dist/types/web/search/types.d.ts +2 -1
  41. package/package.json +9 -9
  42. package/src/cli/dry-balance-cli.ts +823 -0
  43. package/src/cli/session-picker.ts +1 -0
  44. package/src/cli/update-cli.ts +54 -2
  45. package/src/cli-commands.ts +1 -0
  46. package/src/commands/completions.ts +1 -1
  47. package/src/commands/dry-balance.ts +43 -0
  48. package/src/config/append-only-context-mode.ts +37 -0
  49. package/src/config/model-registry.ts +6 -0
  50. package/src/config/models-config-schema.ts +3 -0
  51. package/src/config/settings-schema.ts +2 -2
  52. package/src/config/settings.ts +38 -0
  53. package/src/discovery/builtin-rules/ts-no-tiny-functions.md +1 -0
  54. package/src/discovery/github.ts +37 -1
  55. package/src/discovery/helpers.ts +3 -1
  56. package/src/exa/mcp-client.ts +11 -5
  57. package/src/extensibility/plugins/legacy-pi-compat.ts +245 -25
  58. package/src/hindsight/backend.ts +184 -35
  59. package/src/hindsight/bank.ts +32 -22
  60. package/src/hindsight/mental-models.ts +1 -1
  61. package/src/hindsight/state.ts +21 -7
  62. package/src/internal-urls/docs-index.generated.ts +5 -5
  63. package/src/internal-urls/omp-protocol.ts +8 -2
  64. package/src/main.ts +4 -2
  65. package/src/mcp/json-rpc.ts +8 -0
  66. package/src/mcp/manager.ts +40 -21
  67. package/src/mcp/render.ts +3 -0
  68. package/src/mcp/tool-bridge.ts +10 -2
  69. package/src/mcp/transports/http.ts +33 -16
  70. package/src/mnemopi/state.ts +4 -4
  71. package/src/modes/acp/acp-agent.ts +168 -3
  72. package/src/modes/components/agent-dashboard.ts +103 -31
  73. package/src/modes/components/extensions/extension-dashboard.ts +56 -10
  74. package/src/modes/components/history-search.ts +128 -14
  75. package/src/modes/components/plugin-settings.ts +270 -36
  76. package/src/modes/components/session-selector.ts +45 -14
  77. package/src/modes/components/settings-selector.ts +1 -1
  78. package/src/modes/components/tips.txt +5 -1
  79. package/src/modes/components/transcript-container.ts +35 -6
  80. package/src/modes/components/tree-selector.ts +29 -2
  81. package/src/modes/controllers/command-controller.ts +4 -3
  82. package/src/modes/controllers/input-controller.ts +18 -7
  83. package/src/modes/controllers/selector-controller.ts +30 -19
  84. package/src/modes/interactive-mode.ts +38 -3
  85. package/src/modes/setup-wizard/scenes/sign-in.ts +27 -7
  86. package/src/modes/utils/keybinding-matchers.ts +10 -0
  87. package/src/prompts/agents/explore.md +1 -0
  88. package/src/prompts/agents/librarian.md +1 -0
  89. package/src/prompts/dry-balance-bench.md +8 -0
  90. package/src/prompts/steering/user-interjection.md +10 -0
  91. package/src/prompts/system/agent-creation-architect.md +1 -26
  92. package/src/prompts/system/system-prompt.md +143 -145
  93. package/src/prompts/system/title-system.md +3 -2
  94. package/src/prompts/tools/browser.md +29 -29
  95. package/src/prompts/tools/render-mermaid.md +2 -2
  96. package/src/sdk.ts +87 -30
  97. package/src/session/agent-session.ts +96 -14
  98. package/src/session/auth-storage.ts +4 -0
  99. package/src/session/history-storage.ts +11 -18
  100. package/src/session/messages.ts +80 -0
  101. package/src/session/session-manager.ts +7 -1
  102. package/src/slash-commands/types.ts +27 -10
  103. package/src/task/executor.ts +6 -2
  104. package/src/task/index.ts +8 -7
  105. package/src/task/types.ts +2 -0
  106. package/src/tiny/text.ts +112 -1
  107. package/src/tools/bash.ts +3 -4
  108. package/src/tools/index.ts +16 -0
  109. package/src/tools/job.ts +3 -3
  110. package/src/tools/memory-recall.ts +1 -1
  111. package/src/tools/memory-reflect.ts +3 -3
  112. package/src/tools/path-utils.ts +21 -0
  113. package/src/tools/search.ts +18 -1
  114. package/src/tools/ssh.ts +26 -10
  115. package/src/tools/write.ts +14 -2
  116. package/src/tui/status-line.ts +15 -4
  117. package/src/utils/file-mentions.ts +7 -107
  118. package/src/utils/title-generator.ts +66 -38
  119. package/src/web/search/index.ts +3 -1
  120. package/src/web/search/provider.ts +1 -1
  121. package/src/web/search/providers/base.ts +17 -0
  122. package/src/web/search/providers/exa.ts +111 -7
  123. package/src/web/search/providers/perplexity.ts +8 -4
  124. package/src/web/search/types.ts +2 -1
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Controls:
9
9
  * - Up/Down or j/k: move selection
10
- * - Tab / Shift+Tab: switch source tab
10
+ * - Tab / Shift+Tab or Left/Right: switch source tab
11
11
  * - Space: enable/disable selected agent
12
12
  * - Enter: edit model override for selected agent
13
13
  * - N: start agent creation flow
@@ -20,6 +20,7 @@ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
20
20
  import {
21
21
  type Component,
22
22
  Container,
23
+ Editor,
23
24
  extractPrintableText,
24
25
  fuzzyMatch,
25
26
  Input,
@@ -49,7 +50,7 @@ import { createAgentSession } from "../../sdk";
49
50
  import { discoverAgents } from "../../task/discovery";
50
51
  import type { AgentDefinition, AgentSource } from "../../task/types";
51
52
  import { shortenPath } from "../../tools/render-utils";
52
- import { theme } from "../theme/theme";
53
+ import { getEditorTheme, theme } from "../theme/theme";
53
54
  import { matchesAppInterrupt, matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
54
55
  import { DynamicBorder } from "./dynamic-border";
55
56
 
@@ -97,8 +98,10 @@ const SOURCE_LABEL: Record<AgentSource, string> = {
97
98
  bundled: "Bundled",
98
99
  };
99
100
 
100
- const IDENTIFIER_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+){1,5}$/;
101
+ const LIST_FOOTER =
102
+ " ↑/↓: navigate Space: toggle Enter: model override N: new agent ←/→: source Ctrl+R: reload Esc: close";
101
103
 
104
+ const IDENTIFIER_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+){1,5}$/;
102
105
  function joinPatterns(patterns: string[]): string {
103
106
  if (patterns.length === 0) return "(session model)";
104
107
  return patterns.join(", ");
@@ -307,7 +310,7 @@ class TwoColumnBody implements Component {
307
310
  const rightWidth = width - leftWidth - 3;
308
311
  const leftLines = this.leftPane.render(leftWidth);
309
312
  const rightLines = this.rightPane.render(rightWidth);
310
- const lineCount = Math.min(this.maxHeight, Math.max(leftLines.length, rightLines.length));
313
+ const lineCount = this.maxHeight;
311
314
  const out: string[] = [];
312
315
  const separator = theme.fg("dim", ` ${theme.boxSharp.vertical} `);
313
316
 
@@ -339,11 +342,13 @@ export class AgentDashboard extends Container {
339
342
  #loading = true;
340
343
  #loadError: string | null = null;
341
344
  #notice: string | null = null;
345
+ #builtRows = -1;
346
+ #builtCols = -1;
342
347
 
343
348
  #editInput: Input | null = null;
344
349
  #editingAgentName: string | null = null;
345
350
 
346
- #createInput: Input | null = null;
351
+ #createInput: Editor | null = null;
347
352
  #createDescription = "";
348
353
  #createScope: AgentScope = "project";
349
354
  #createGenerating = false;
@@ -466,8 +471,46 @@ export class AgentDashboard extends Container {
466
471
  this.#clampSelection();
467
472
  }
468
473
 
474
+ /** Live terminal height so the dashboard tracks resize while open. */
475
+ #terminalRows(): number {
476
+ return process.stdout.rows || this.terminalHeight || 24;
477
+ }
478
+
479
+ #noticeBlockLines(): number {
480
+ if (!this.#notice) return 0;
481
+ return wrapTextWithAnsi(theme.fg("success", replaceTabs(this.#notice)), this.#uiWidth()).length + 1;
482
+ }
483
+
484
+ #footerLines(): number {
485
+ return Math.max(1, wrapTextWithAnsi(theme.fg("dim", LIST_FOOTER), this.#uiWidth()).length);
486
+ }
487
+
488
+ /** Height budget for the two-column body, sized to the live terminal. */
489
+ #computeBodyHeight(): number {
490
+ // Chrome around the body: top border + title + tab bar + spacer (4),
491
+ // optional notice block, then spacer + footer + bottom border.
492
+ const chrome = 4 + this.#noticeBlockLines() + 1 + this.#footerLines() + 1;
493
+ return Math.max(5, this.#terminalRows() - chrome);
494
+ }
495
+
469
496
  #getMaxVisibleItems(): number {
470
- return Math.max(5, this.terminalHeight - 14);
497
+ // List pane chrome inside the body: search line, blank line, count line.
498
+ return Math.max(3, this.#computeBodyHeight() - 3);
499
+ }
500
+
501
+ override render(width: number): string[] {
502
+ // Rebuild when terminal geometry changes so the full-screen overlay
503
+ // re-fits on resize.
504
+ if (this.#terminalRows() !== this.#builtRows || this.#uiWidth() !== this.#builtCols) {
505
+ this.#buildLayout();
506
+ }
507
+ const lines = super.render(width);
508
+ // Pad to the full viewport so every state (list, edit, create) covers the
509
+ // screen as a true full-screen view instead of letting the transcript peek
510
+ // through below it.
511
+ const rows = this.#terminalRows();
512
+ while (lines.length < rows) lines.push("");
513
+ return lines;
471
514
  }
472
515
 
473
516
  #clampSelection(): void {
@@ -557,10 +600,15 @@ export class AgentDashboard extends Container {
557
600
  this.#createError = null;
558
601
  this.#createSpec = null;
559
602
  this.#createDescription = "";
560
- this.#createInput = new Input();
561
- this.#createInput.onSubmit = value => {
562
- void this.#generateAgentFromDescription(value);
603
+ const editor = new Editor(getEditorTheme());
604
+ editor.setBorderVisible(false);
605
+ editor.setPromptGutter("> ");
606
+ editor.setMaxHeight(Math.max(3, Math.min(8, this.#terminalRows() - 12)));
607
+ editor.disableSubmit = true;
608
+ editor.onChange = value => {
609
+ this.#createDescription = value;
563
610
  };
611
+ this.#createInput = editor;
564
612
  this.#buildLayout();
565
613
  }
566
614
 
@@ -578,6 +626,20 @@ export class AgentDashboard extends Container {
578
626
  this.#buildLayout();
579
627
  }
580
628
 
629
+ #submitCreateDescription(): void {
630
+ if (!this.#createInput || this.#createGenerating) return;
631
+ const description = this.#createInput.getExpandedText();
632
+ this.#createDescription = description;
633
+ void this.#generateAgentFromDescription(description);
634
+ }
635
+
636
+ #insertCreateNewline(): void {
637
+ if (!this.#createInput || this.#createGenerating) return;
638
+ this.#createInput.handleInput("\n");
639
+ this.#createDescription = this.#createInput.getExpandedText();
640
+ this.#buildLayout();
641
+ }
642
+
581
643
  async #generateAgentFromDescription(rawDescription: string): Promise<void> {
582
644
  const description = rawDescription.trim();
583
645
  this.#createDescription = description;
@@ -626,7 +688,7 @@ export class AgentDashboard extends Container {
626
688
  throw new Error("No available model to generate agent specification.");
627
689
  }
628
690
 
629
- const systemPrompt = prompt.render(agentCreationArchitectPrompt, { TASK_TOOL_NAME: "task" });
691
+ const systemPrompt = prompt.render(agentCreationArchitectPrompt, {});
630
692
  const userPrompt = prompt.render(agentCreationUserPrompt, { request: description });
631
693
 
632
694
  const { session } = await createAgentSession({
@@ -694,10 +756,14 @@ export class AgentDashboard extends Container {
694
756
  }
695
757
  }
696
758
 
697
- const frontmatter = YAML.stringify({
698
- name: spec.identifier,
699
- description: spec.whenToUse,
700
- }).trimEnd();
759
+ const frontmatter = YAML.stringify(
760
+ {
761
+ name: spec.identifier,
762
+ description: spec.whenToUse,
763
+ },
764
+ null,
765
+ 2,
766
+ ).trimEnd();
701
767
  const content = `---\n${frontmatter}\n---\n\n${spec.systemPrompt.trim()}\n`;
702
768
  await Bun.write(filePath, content);
703
769
  await this.#reloadData();
@@ -789,13 +855,13 @@ export class AgentDashboard extends Container {
789
855
  }
790
856
  return parts.join("");
791
857
  }
792
-
793
858
  #renderCreateInput(): void {
794
859
  this.addChild(new Text(theme.bold(theme.fg("accent", " Create New Agent")), 0, 0));
795
860
  this.addChild(new Spacer(1));
796
861
  this.addChild(new Text(theme.fg("muted", "Describe what the new agent should do:"), 0, 0));
797
862
  this.addChild(new Spacer(1));
798
863
  if (this.#createInput) {
864
+ this.#createInput.setMaxHeight(Math.max(3, Math.min(8, this.#terminalRows() - 12)));
799
865
  this.addChild(this.#createInput);
800
866
  }
801
867
  this.addChild(new Spacer(1));
@@ -805,7 +871,7 @@ export class AgentDashboard extends Container {
805
871
  this.addChild(new Text(theme.fg("accent", "Generating agent specification..."), 0, 0));
806
872
  if (this.#createStreamingText) {
807
873
  this.addChild(new Spacer(1));
808
- const maxPreview = Math.max(3, this.terminalHeight - 18);
874
+ const maxPreview = Math.max(3, this.#terminalRows() - 18);
809
875
  const contentWidth = Math.max(20, this.#uiWidth() - 4);
810
876
  const wrappedLines: string[] = [];
811
877
  for (const raw of this.#createStreamingText.split("\n")) {
@@ -826,7 +892,9 @@ export class AgentDashboard extends Container {
826
892
  this.addChild(new Text(theme.fg("error", replaceTabs(this.#createError)), 0, 0));
827
893
  }
828
894
  this.addChild(new Spacer(1));
829
- const hints = this.#createGenerating ? " Generating..." : " Enter: generate Tab: toggle scope Esc: cancel";
895
+ const hints = this.#createGenerating
896
+ ? " Generating..."
897
+ : " Ctrl+Enter: generate Enter: newline Tab: toggle scope Esc: cancel";
830
898
  this.addChild(new Text(theme.fg("dim", hints), 0, 0));
831
899
  }
832
900
 
@@ -968,22 +1036,15 @@ export class AgentDashboard extends Container {
968
1036
  effectivePatterns,
969
1037
  effectiveResolution,
970
1038
  );
971
- const bodyHeight = Math.max(5, this.terminalHeight - 8);
1039
+ const bodyHeight = this.#computeBodyHeight();
972
1040
  this.addChild(new TwoColumnBody(listPane, inspector, bodyHeight));
973
1041
  this.addChild(new Spacer(1));
974
- this.addChild(
975
- new Text(
976
- theme.fg(
977
- "dim",
978
- " ↑/↓: navigate Space: toggle Enter: model override N: new agent Tab: source Ctrl+R: reload Esc: close",
979
- ),
980
- 0,
981
- 0,
982
- ),
983
- );
1042
+ this.addChild(new Text(theme.fg("dim", LIST_FOOTER), 0, 0));
984
1043
  }
985
1044
 
986
1045
  this.addChild(new DynamicBorder());
1046
+ this.#builtRows = this.#terminalRows();
1047
+ this.#builtCols = this.#uiWidth();
987
1048
  }
988
1049
 
989
1050
  handleInput(data: string): void {
@@ -1024,13 +1085,24 @@ export class AgentDashboard extends Container {
1024
1085
  }
1025
1086
  return;
1026
1087
  }
1088
+ if (
1089
+ !this.#createGenerating &&
1090
+ (matchesKey(data, "ctrl+enter") || (data.charCodeAt(0) === 10 && data.length > 1))
1091
+ ) {
1092
+ this.#submitCreateDescription();
1093
+ return;
1094
+ }
1095
+ if (!this.#createGenerating && (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n")) {
1096
+ this.#insertCreateNewline();
1097
+ return;
1098
+ }
1027
1099
  if (!this.#createGenerating && (matchesKey(data, "tab") || matchesKey(data, "shift+tab"))) {
1028
1100
  this.#toggleCreateScope();
1029
1101
  return;
1030
1102
  }
1031
1103
  if (!this.#createGenerating && this.#createInput) {
1032
1104
  this.#createInput.handleInput(data);
1033
- this.#createDescription = this.#createInput.getValue();
1105
+ this.#createDescription = this.#createInput.getExpandedText();
1034
1106
  this.#buildLayout();
1035
1107
  }
1036
1108
  return;
@@ -1064,11 +1136,11 @@ export class AgentDashboard extends Container {
1064
1136
  return;
1065
1137
  }
1066
1138
 
1067
- if (matchesKey(data, "tab")) {
1139
+ if (matchesKey(data, "tab") || matchesKey(data, "right")) {
1068
1140
  this.#switchTab(1);
1069
1141
  return;
1070
1142
  }
1071
- if (matchesKey(data, "shift+tab")) {
1143
+ if (matchesKey(data, "shift+tab") || matchesKey(data, "left")) {
1072
1144
  this.#switchTab(-1);
1073
1145
  return;
1074
1146
  }
@@ -20,6 +20,7 @@ import {
20
20
  Text,
21
21
  truncateToWidth,
22
22
  visibleWidth,
23
+ wrapTextWithAnsi,
23
24
  } from "@oh-my-pi/pi-tui";
24
25
  import { Settings } from "../../../config/settings";
25
26
  import { DynamicBorder } from "../../../modes/components/dynamic-border";
@@ -37,11 +38,15 @@ import {
37
38
  } from "./state-manager";
38
39
  import type { DashboardState } from "./types";
39
40
 
41
+ const EXT_FOOTER = " ↑/↓: navigate Space: toggle ←/→: provider Esc: close";
42
+
40
43
  export class ExtensionDashboard extends Container {
41
44
  #state!: DashboardState;
42
45
  #mainList!: ExtensionList;
43
46
  #inspector!: InspectorPanel;
44
47
  #refreshToken = 0;
48
+ #builtRows = -1;
49
+ #builtCols = -1;
45
50
 
46
51
  onClose?: () => void;
47
52
  onRequestRender?: () => void;
@@ -71,7 +76,7 @@ export class ExtensionDashboard extends Container {
71
76
 
72
77
  // Calculate max visible items based on terminal height
73
78
  // Reserve ~10 lines for header, tabs, help text, borders
74
- const maxVisible = Math.max(5, Math.floor((this.terminalHeight - 10) / 2));
79
+ const maxVisible = this.#maxVisibleItems();
75
80
 
76
81
  // Create main list - always focused
77
82
  this.#mainList = new ExtensionList(
@@ -107,6 +112,45 @@ export class ExtensionDashboard extends Container {
107
112
  return tab && tab.id !== "all" ? tab.id : null;
108
113
  }
109
114
 
115
+ /** Live terminal height so the dashboard tracks resize while open. */
116
+ #terminalRows(): number {
117
+ return process.stdout.rows || this.terminalHeight || 24;
118
+ }
119
+
120
+ #uiWidth(): number {
121
+ return Math.max(20, process.stdout.columns || 80);
122
+ }
123
+
124
+ #footerLines(): number {
125
+ return Math.max(1, wrapTextWithAnsi(theme.fg("dim", EXT_FOOTER), this.#uiWidth()).length);
126
+ }
127
+
128
+ /** Height budget for the two-column body, sized to the live terminal. */
129
+ #computeBodyHeight(): number {
130
+ // Chrome: top border + title + tab bar + spacer (4), then spacer + footer + bottom border.
131
+ const chrome = 4 + 1 + this.#footerLines() + 1;
132
+ return Math.max(5, this.#terminalRows() - chrome);
133
+ }
134
+
135
+ #maxVisibleItems(): number {
136
+ // List chrome inside the body: search line, blank line, scroll indicator.
137
+ return Math.max(3, this.#computeBodyHeight() - 3);
138
+ }
139
+
140
+ override render(width: number): string[] {
141
+ // Rebuild when terminal geometry changes so the full-screen overlay
142
+ // re-fits on resize.
143
+ if (this.#terminalRows() !== this.#builtRows || this.#uiWidth() !== this.#builtCols) {
144
+ this.#buildLayout();
145
+ }
146
+ const lines = super.render(width);
147
+ // Pad to the full viewport so the dashboard covers the screen instead of
148
+ // letting the transcript peek through below it.
149
+ const rows = this.#terminalRows();
150
+ while (lines.length < rows) lines.push("");
151
+ return lines;
152
+ }
153
+
110
154
  #buildLayout(): void {
111
155
  this.clear();
112
156
 
@@ -120,16 +164,18 @@ export class ExtensionDashboard extends Container {
120
164
  this.addChild(new Text(this.#renderTabBar(), 0, 0));
121
165
  this.addChild(new Spacer(1));
122
166
 
123
- // 2-column body with height limit
124
- // Reserve ~8 lines for header, tabs, help text, borders
125
- const bodyMaxHeight = Math.max(5, this.terminalHeight - 8);
167
+ // 2-column body sized to fill the live terminal viewport.
168
+ const bodyMaxHeight = this.#computeBodyHeight();
169
+ this.#mainList.setMaxVisible(this.#maxVisibleItems());
126
170
  this.addChild(new TwoColumnBody(this.#mainList, this.#inspector, bodyMaxHeight));
127
171
 
128
172
  this.addChild(new Spacer(1));
129
- this.addChild(new Text(theme.fg("dim", " ↑/↓: navigate Space: toggle Tab: next provider Esc: close"), 0, 0));
173
+ this.addChild(new Text(theme.fg("dim", EXT_FOOTER), 0, 0));
130
174
 
131
175
  // Bottom border
132
176
  this.addChild(new DynamicBorder());
177
+ this.#builtRows = this.#terminalRows();
178
+ this.#builtCols = this.#uiWidth();
133
179
  }
134
180
 
135
181
  #renderTabBar(): string {
@@ -289,12 +335,12 @@ export class ExtensionDashboard extends Container {
289
335
  return;
290
336
  }
291
337
 
292
- // Tab/Shift+Tab: Cycle through tabs
293
- if (matchesKey(data, "tab")) {
338
+ // Tab/Shift+Tab or Left/Right: Cycle through tabs
339
+ if (matchesKey(data, "tab") || matchesKey(data, "right")) {
294
340
  this.#switchTab(1);
295
341
  return;
296
342
  }
297
- if (matchesKey(data, "shift+tab")) {
343
+ if (matchesKey(data, "shift+tab") || matchesKey(data, "left")) {
298
344
  this.#switchTab(-1);
299
345
  return;
300
346
  }
@@ -328,8 +374,8 @@ class TwoColumnBody implements Component {
328
374
  const leftLines = this.leftPane.render(leftWidth);
329
375
  const rightLines = this.rightPane.render(rightWidth);
330
376
 
331
- // Limit to maxHeight lines
332
- const numLines = Math.min(this.maxHeight, Math.max(leftLines.length, rightLines.length));
377
+ // Fill the full body height so the dashboard reads as a full-screen view.
378
+ const numLines = this.maxHeight;
333
379
  const combined: string[] = [];
334
380
  const separator = theme.fg("dim", ` ${theme.boxSharp.vertical} `);
335
381
 
@@ -11,18 +11,82 @@ import {
11
11
  visibleWidth,
12
12
  } from "@oh-my-pi/pi-tui";
13
13
  import { theme } from "../../modes/theme/theme";
14
- import { matchesAppInterrupt, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers";
14
+ import {
15
+ matchesAppInterrupt,
16
+ matchesSelectDown,
17
+ matchesSelectPageDown,
18
+ matchesSelectPageUp,
19
+ matchesSelectUp,
20
+ } from "../../modes/utils/keybinding-matchers";
15
21
  import type { HistoryEntry, HistoryStorage } from "../../session/history-storage";
16
22
  import { DynamicBorder } from "./dynamic-border";
23
+ import { rawKeyHint } from "./keybinding-hints";
24
+
25
+ /** Visible result rows; also the jump distance for PageUp/PageDown. */
26
+ const MAX_VISIBLE = 10;
27
+
28
+ /** Split a query the same way `HistoryStorage` tokenizes it, so highlights align with matches. */
29
+ function queryTokens(query: string): string[] {
30
+ return query
31
+ .toLowerCase()
32
+ .split(/[^\p{L}\p{N}]+/u)
33
+ .filter(tok => tok.length > 0);
34
+ }
35
+
36
+ /** Wrap every case-insensitive occurrence of any token in `text` with the accent color. */
37
+ function highlightTokens(text: string, tokens: string[]): string {
38
+ if (tokens.length === 0) return text;
39
+
40
+ const lower = text.toLowerCase();
41
+ const ranges: Array<[number, number]> = [];
42
+ for (const tok of tokens) {
43
+ let from = lower.indexOf(tok);
44
+ while (from !== -1) {
45
+ ranges.push([from, from + tok.length]);
46
+ from = lower.indexOf(tok, from + tok.length);
47
+ }
48
+ }
49
+ if (ranges.length === 0) return text;
50
+
51
+ ranges.sort((a, b) => a[0] - b[0]);
52
+ let out = "";
53
+ let pos = 0;
54
+ for (const [start, end] of ranges) {
55
+ if (end <= pos) continue; // fully covered by a previous (merged) range
56
+ const from = Math.max(start, pos);
57
+ if (from > pos) out += text.slice(pos, from);
58
+ out += theme.fg("accent", text.slice(from, end));
59
+ pos = end;
60
+ }
61
+ if (pos < text.length) out += text.slice(pos);
62
+ return out;
63
+ }
64
+
65
+ /** Compact "time since" label (e.g. `now`, `5m`, `2h`, `3d`, `2w`, `6mo`, `1y`) from epoch seconds. */
66
+ function relativeTime(epochSeconds: number): string {
67
+ const seconds = Math.max(0, Math.floor(Date.now() / 1000) - epochSeconds);
68
+ if (seconds < 60) return "now";
69
+ const minutes = Math.floor(seconds / 60);
70
+ if (minutes < 60) return `${minutes}m`;
71
+ const hours = Math.floor(minutes / 60);
72
+ if (hours < 24) return `${hours}h`;
73
+ const days = Math.floor(hours / 24);
74
+ if (days < 7) return `${days}d`;
75
+ if (days < 30) return `${Math.floor(days / 7)}w`;
76
+ if (days < 365) return `${Math.floor(days / 30)}mo`;
77
+ return `${Math.floor(days / 365)}y`;
78
+ }
17
79
 
18
80
  class HistoryResultsList implements Component {
19
81
  #results: HistoryEntry[] = [];
82
+ #tokens: string[] = [];
20
83
  #selectedIndex = 0;
21
- #maxVisible = 10;
84
+ #maxVisible = MAX_VISIBLE;
22
85
 
23
- setResults(results: HistoryEntry[], selectedIndex: number): void {
86
+ setResults(results: HistoryEntry[], selectedIndex: number, tokens: string[]): void {
24
87
  this.#results = results;
25
88
  this.#selectedIndex = selectedIndex;
89
+ this.#tokens = tokens;
26
90
  }
27
91
 
28
92
  setSelectedIndex(selectedIndex: number): void {
@@ -37,10 +101,14 @@ class HistoryResultsList implements Component {
37
101
  const lines: string[] = [];
38
102
 
39
103
  if (this.#results.length === 0) {
40
- lines.push(theme.fg("muted", " No matching history"));
104
+ const message = this.#tokens.length > 0 ? "No matching history" : "No history yet";
105
+ lines.push(theme.fg("muted", ` ${theme.status.info} ${message}`));
41
106
  return lines;
42
107
  }
43
108
 
109
+ const cursorSymbol = `${theme.nav.cursor} `;
110
+ const gutterWidth = visibleWidth(cursorSymbol);
111
+
44
112
  const startIndex = Math.max(
45
113
  0,
46
114
  Math.min(this.#selectedIndex - Math.floor(this.#maxVisible / 2), this.#results.length - this.#maxVisible),
@@ -51,18 +119,32 @@ class HistoryResultsList implements Component {
51
119
  const entry = this.#results[i];
52
120
  const isSelected = i === this.#selectedIndex;
53
121
 
54
- const cursorSymbol = `${theme.nav.cursor} `;
55
- const cursorWidth = visibleWidth(cursorSymbol);
56
- const cursor = isSelected ? theme.fg("accent", cursorSymbol) : padding(cursorWidth);
57
- const maxWidth = width - cursorWidth;
122
+ const timeStr = relativeTime(entry.created_at);
123
+ const timeWidth = visibleWidth(timeStr);
124
+ const showTime = width >= gutterWidth + 12 + timeWidth;
58
125
 
126
+ const promptBudget = Math.max(4, width - gutterWidth - (showTime ? timeWidth + 1 : 0));
59
127
  const normalized = entry.prompt.replace(/\s+/g, " ").trim();
60
- const truncated = truncateToWidth(normalized, maxWidth);
61
- lines.push(cursor + (isSelected ? theme.bold(truncated) : truncated));
128
+ const plain = truncateToWidth(normalized, promptBudget);
129
+ const highlighted = highlightTokens(plain, this.#tokens);
130
+
131
+ const cursor = isSelected ? theme.fg("accent", cursorSymbol) : padding(gutterWidth);
132
+ let line = cursor + (isSelected ? theme.bold(highlighted) : highlighted);
133
+
134
+ if (showTime) {
135
+ // Pad the prompt region so the timestamp sits flush right with a one-cell gap.
136
+ line = `${truncateToWidth(line, width - timeWidth - 1, Ellipsis.Unicode, true)} ${theme.fg("dim", timeStr)}`;
137
+ }
138
+
139
+ lines.push(
140
+ isSelected
141
+ ? theme.bg("selectedBg", truncateToWidth(line, width, Ellipsis.Omit, true))
142
+ : truncateToWidth(line, width),
143
+ );
62
144
  }
63
145
 
64
146
  if (startIndex > 0 || endIndex < this.#results.length) {
65
- const scrollText = ` (${this.#selectedIndex + 1}/${this.#results.length})`;
147
+ const scrollText = ` ${this.#selectedIndex + 1}/${this.#results.length}`;
66
148
  lines.push(theme.fg("muted", truncateToWidth(scrollText, width, Ellipsis.Omit)));
67
149
  }
68
150
 
@@ -99,8 +181,12 @@ export class HistorySearchComponent extends Container {
99
181
 
100
182
  this.#resultsList = new HistoryResultsList();
101
183
 
184
+ const title = theme.bold(theme.fg("accent", `${theme.icon.rewind} Search History`));
185
+ const dot = theme.fg("dim", theme.sep.dot);
186
+ const hint = [rawKeyHint("↑↓", "navigate"), rawKeyHint("enter", "select"), rawKeyHint("esc", "cancel")].join(dot);
187
+
102
188
  this.addChild(new Spacer(1));
103
- this.addChild(new Text(theme.bold("Search History (Ctrl+R)"), 1, 0));
189
+ this.addChild(new Text(title, 1, 0));
104
190
  this.addChild(new Spacer(1));
105
191
  this.addChild(new DynamicBorder());
106
192
  this.addChild(new Spacer(1));
@@ -108,7 +194,7 @@ export class HistorySearchComponent extends Container {
108
194
  this.addChild(new Spacer(1));
109
195
  this.addChild(this.#resultsList);
110
196
  this.addChild(new Spacer(1));
111
- this.addChild(new Text(theme.fg("muted", "up/down navigate enter select esc cancel"), 1, 0));
197
+ this.addChild(new Text(hint, 1, 0));
112
198
  this.addChild(new Spacer(1));
113
199
  this.addChild(new DynamicBorder());
114
200
 
@@ -130,6 +216,34 @@ export class HistorySearchComponent extends Container {
130
216
  return;
131
217
  }
132
218
 
219
+ if (matchesSelectPageUp(keyData)) {
220
+ if (this.#results.length === 0) return;
221
+ this.#selectedIndex = Math.max(0, this.#selectedIndex - MAX_VISIBLE);
222
+ this.#resultsList.setSelectedIndex(this.#selectedIndex);
223
+ return;
224
+ }
225
+
226
+ if (matchesSelectPageDown(keyData)) {
227
+ if (this.#results.length === 0) return;
228
+ this.#selectedIndex = Math.min(this.#results.length - 1, this.#selectedIndex + MAX_VISIBLE);
229
+ this.#resultsList.setSelectedIndex(this.#selectedIndex);
230
+ return;
231
+ }
232
+
233
+ if (matchesKey(keyData, "home")) {
234
+ if (this.#results.length === 0) return;
235
+ this.#selectedIndex = 0;
236
+ this.#resultsList.setSelectedIndex(this.#selectedIndex);
237
+ return;
238
+ }
239
+
240
+ if (matchesKey(keyData, "end")) {
241
+ if (this.#results.length === 0) return;
242
+ this.#selectedIndex = this.#results.length - 1;
243
+ this.#resultsList.setSelectedIndex(this.#selectedIndex);
244
+ return;
245
+ }
246
+
133
247
  if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
134
248
  const selected = this.#results[this.#selectedIndex];
135
249
  if (selected) {
@@ -153,6 +267,6 @@ export class HistorySearchComponent extends Container {
153
267
  ? this.#historyStorage.search(query, this.#resultLimit)
154
268
  : this.#historyStorage.getRecent(this.#resultLimit);
155
269
  this.#selectedIndex = 0;
156
- this.#resultsList.setResults(this.#results, this.#selectedIndex);
270
+ this.#resultsList.setResults(this.#results, this.#selectedIndex, query ? queryTokens(query) : []);
157
271
  }
158
272
  }