@timurproko/a1 0.2.5-dev.683 → 0.2.5-dev.698

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 (62) hide show
  1. package/dist/app/session-shell/prompt-suggestion-controller.d.ts +2 -0
  2. package/dist/app/session-shell/prompt-suggestion-controller.js +10 -0
  3. package/dist/app/session-shell/session-shell-root.js +15 -2
  4. package/dist/app/session-shell/session-shell.js +71 -15
  5. package/dist/app/session-shell/share-operation.d.ts +3 -0
  6. package/dist/app/session-shell/share-operation.js +11 -0
  7. package/dist/composition/settings-route-host.js +1 -1
  8. package/dist/contracts/presentation/index.d.ts +5 -0
  9. package/dist/contracts/presentation/index.js +2 -0
  10. package/dist/features/owned-ui/project-trust-dialog.js +7 -5
  11. package/dist/features/owned-ui/reference-screen-app.d.ts +2 -2
  12. package/dist/features/owned-ui/reference-screen-app.js +10 -6
  13. package/dist/features/owned-ui/settings-app.js +10 -5
  14. package/dist/integrations/pi/components/index.d.ts +3 -2
  15. package/dist/integrations/pi/components/index.js +2 -1
  16. package/dist/integrations/pi/components/modal-frame.d.ts +11 -1
  17. package/dist/integrations/pi/components/modal-frame.js +29 -1
  18. package/dist/integrations/pi/components/models-dialog.js +17 -17
  19. package/dist/integrations/pi/components/share-operation-dialog.d.ts +6 -0
  20. package/dist/integrations/pi/components/share-operation-dialog.js +40 -0
  21. package/dist/integrations/pi/components/shell-editor-autocomplete.js +15 -6
  22. package/dist/integrations/pi/components/shell-extension-ui.d.ts +3 -0
  23. package/dist/integrations/pi/components/shell-extension-ui.js +8 -4
  24. package/dist/integrations/pi/components/shell-selectors-dialogs.d.ts +0 -4
  25. package/dist/integrations/pi/components/shell-selectors-dialogs.js +56 -34
  26. package/dist/integrations/pi/components/shell-session-info-reference.js +35 -2
  27. package/dist/integrations/pi/components/shell-shared-facade.d.ts +3 -1
  28. package/dist/integrations/pi/components/shell-shared-facade.js +13 -1
  29. package/dist/integrations/pi/components/skills-dialog.js +19 -6
  30. package/dist/integrations/pi/components/theme.d.ts +8 -1
  31. package/dist/integrations/pi/components/theme.js +28 -1
  32. package/dist/integrations/pi/components/upstream/adjacent/core/keybindings.d.ts +10 -2
  33. package/dist/integrations/pi/components/upstream/adjacent/core/keybindings.js +20 -6
  34. package/dist/integrations/pi/components/upstream/components/extension-editor.js +2 -2
  35. package/dist/integrations/pi/components/upstream/components/extension-input.js +4 -5
  36. package/dist/integrations/pi/components/upstream/components/extension-selector.js +2 -3
  37. package/dist/integrations/pi/components/upstream/components/scoped-models-selector.d.ts +3 -2
  38. package/dist/integrations/pi/components/upstream/components/scoped-models-selector.js +4 -15
  39. package/dist/integrations/pi/components/upstream/components/session-selector.d.ts +2 -0
  40. package/dist/integrations/pi/components/upstream/components/session-selector.js +136 -109
  41. package/dist/integrations/pi/components/upstream/components/thinking-selector.d.ts +8 -5
  42. package/dist/integrations/pi/components/upstream/components/thinking-selector.js +63 -26
  43. package/dist/integrations/pi/components/upstream/components/tree-selector.d.ts +29 -20
  44. package/dist/integrations/pi/components/upstream/components/tree-selector.js +245 -210
  45. package/dist/integrations/pi/components/upstream/components/trust-selector.js +13 -15
  46. package/dist/integrations/pi/engine/adapter.js +21 -2
  47. package/dist/integrations/pi/engine/workflow-runner.js +2 -1
  48. package/dist/native/darwin-arm64/manifest.json +1 -1
  49. package/dist/native/linux-x64/manifest.json +1 -1
  50. package/dist/native/win32-x64/manifest.json +2 -2
  51. package/dist/native/win32-x64/process-guardian.exe +0 -0
  52. package/dist/ui/apps/host.d.ts +4 -3
  53. package/dist/ui/apps/host.js +9 -4
  54. package/dist/ui/components/dialog-panel.js +6 -7
  55. package/dist/ui/components/index.d.ts +1 -1
  56. package/dist/ui/components/index.js +1 -1
  57. package/dist/ui/components/list-view.d.ts +3 -3
  58. package/dist/ui/components/list-view.js +8 -9
  59. package/dist/ui/components/shortcut-hints.d.ts +2 -0
  60. package/dist/ui/components/shortcut-hints.js +17 -1
  61. package/dist/ui/components/theme.d.ts +1 -1
  62. package/package.json +1 -1
@@ -1,17 +1,23 @@
1
1
  /**
2
2
  * Provenance: @earendil-works/pi-coding-agent 1.0.4 (MIT), commit 7c10bd4337495ee613f2224843ecdf349b80d1df,
3
3
  * packages/coding-agent/src/modes/interactive/components/tree-selector.ts.
4
- * Modifications: Source-synchronized tree selector port: preserve filtering, folding, labels, copying,
5
- * tree navigation, key hints, focus, and viewport behavior while remapping public types/components
6
- * plus owned keybindings/theme helpers required to avoid the pinned package nested pi-tui singleton;
7
- * bare A1 uses the shared semantic modal shortcut row and compact padded modal frame.
8
- * Deviations: owned-modal-shortcut-hints.
4
+ * Modifications: Port remaps public types/components plus owned keybindings/theme helpers while
5
+ * preserving tree behavior; bare A1 uses compact modal chrome and label editing, four-mode
6
+ * Models-style filter status with Tab/Shift+Tab directional cycling and concise all-first presentation
7
+ * excluding internal bookkeeping, standard search input, blue item-bounded menu-arrow selection that
8
+ * preserves per-entry semantic foregrounds and has no path bullets, accent entry labels, bracketed
9
+ * timestamps, and plain label-time status, semantic role colors with session naming for the system
10
+ * root, standard paging/first-last/non-root containing-branch folding keys with a permanently expanded
11
+ * system session entry even behind hidden metadata, single-character ellipses on both clipped edges
12
+ * with bracket-delimiter preservation and selected-fragment highlighting, and Models-ordered semantic
13
+ * shortcut footers without a redundant select hint.
14
+ * Deviations: owned-modal-shortcut-hints, owned-session-tree-dialog.
9
15
  */
10
- import { Container, Input, Spacer, sliceByColumn, Text, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
16
+ import { Container, Input, matchesKey, Spacer, sliceByColumn, Text, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
11
17
  import { DynamicBorder } from "../../../startup-public.js";
12
18
  import { KeybindingsManager } from "../adjacent/core/keybindings.js";
13
19
  import { addPiModalHeader, adoptPiModalFrame } from "../../modal-frame.js";
14
- import { piTheme, renderPiModalShortcutHints } from "../../theme.js";
20
+ import { DIALOG_CLOSE_SHORTCUT_HINT, piTheme, renderPiModalShortcutHints } from "../../theme.js";
15
21
  const theme = new Proxy({}, {
16
22
  get(_target, property) {
17
23
  const active = piTheme();
@@ -19,7 +25,7 @@ const theme = new Proxy({}, {
19
25
  return typeof value === "function" ? value.bind(active) : value;
20
26
  },
21
27
  });
22
- const treeKeybindings = KeybindingsManager.create();
28
+ const treeKeybindings = KeybindingsManager.createForOwnedInput();
23
29
  function formatKeyText(key) {
24
30
  return key
25
31
  .split("/")
@@ -57,26 +63,44 @@ function renderHorizontalViewport(rows, width) {
57
63
  }
58
64
  // Clip only the body; the fixed-width gutter remains visible as navigation context.
59
65
  return rows.map((row) => {
60
- const line = horizontalScroll > 0
61
- ? `${row.gutter}${sliceByColumn(row.body, horizontalScroll, viewportWidth, true)}\x1b[0m`
62
- : row.gutter + row.body;
66
+ if (viewportWidth === 0)
67
+ return truncateToWidth(row.gutter, width, "");
68
+ const leftClipped = horizontalScroll > 0;
69
+ const widthAfterLeftMarker = Math.max(0, viewportWidth - (leftClipped ? 1 : 0));
70
+ const rightClipped = row.bodyWidth - horizontalScroll > widthAfterLeftMarker;
71
+ const rightMarkerText = `…${row.rightClipSuffix}`;
72
+ const rightMarkerWidth = rightClipped ? visibleWidth(rightMarkerText) : 0;
73
+ const bodyWidth = Math.max(0, widthAfterLeftMarker - rightMarkerWidth);
74
+ const marker = (text) => row.isSelected
75
+ ? theme.bg("selectedBg", theme.fg("muted", text))
76
+ : theme.fg("muted", text);
77
+ const body = sliceByColumn(row.body, horizontalScroll, bodyWidth, true);
78
+ const line = `${row.gutter}${leftClipped ? marker("…") : ""}${body}${rightClipped ? marker(rightMarkerText) : ""}\x1b[0m`;
63
79
  return truncateToWidth(line, width, "");
64
80
  });
65
81
  }
82
+ const FILTER_MODES = ["all", "no-tools", "user-only", "labeled-only"];
83
+ const FILTER_LABELS = {
84
+ all: "all",
85
+ default: "all",
86
+ "no-tools": "no tools",
87
+ "user-only": "user",
88
+ "labeled-only": "labeled",
89
+ };
66
90
  class TreeList {
67
91
  flatNodes = [];
68
92
  filteredNodes = [];
69
93
  selectedIndex = 0;
70
94
  currentLeafId;
71
95
  maxVisibleLines;
72
- filterMode = "default";
96
+ filterMode = "all";
73
97
  searchQuery = "";
74
98
  toolCallMap = new Map();
75
99
  multipleRoots = false;
76
100
  showLabelTimestamps = false;
77
- activePathIds = new Set();
78
101
  visibleParentMap = new Map();
79
102
  visibleChildrenMap = new Map();
103
+ sessionRootIds = new Set();
80
104
  lastSelectedId = null;
81
105
  foldedNodes = new Set();
82
106
  onSelect;
@@ -86,10 +110,9 @@ class TreeList {
86
110
  constructor(tree, currentLeafId, maxVisibleLines, initialSelectedId, initialFilterMode) {
87
111
  this.currentLeafId = currentLeafId;
88
112
  this.maxVisibleLines = maxVisibleLines;
89
- this.filterMode = initialFilterMode ?? "default";
113
+ this.filterMode = initialFilterMode && initialFilterMode !== "default" ? initialFilterMode : "all";
90
114
  this.multipleRoots = tree.length > 1;
91
115
  this.flatNodes = this.flattenTree(tree);
92
- this.buildActivePath();
93
116
  this.applyFilter();
94
117
  // Start with initialSelectedId if provided, otherwise current leaf
95
118
  const targetId = initialSelectedId ?? currentLeafId;
@@ -124,29 +147,10 @@ class TreeList {
124
147
  // Fallback: last visible entry
125
148
  return this.filteredNodes.length - 1;
126
149
  }
127
- /** Build the set of entry IDs on the path from root to current leaf */
128
- buildActivePath() {
129
- this.activePathIds.clear();
130
- if (!this.currentLeafId)
131
- return;
132
- // Build a map of id -> entry for parent lookup
133
- const entryMap = new Map();
134
- for (const flatNode of this.flatNodes) {
135
- entryMap.set(flatNode.node.entry.id, flatNode);
136
- }
137
- // Walk from leaf to root
138
- let currentId = this.currentLeafId;
139
- while (currentId) {
140
- this.activePathIds.add(currentId);
141
- const node = entryMap.get(currentId);
142
- if (!node)
143
- break;
144
- currentId = node.node.entry.parentId ?? null;
145
- }
146
- }
147
150
  flattenTree(roots) {
148
151
  const result = [];
149
152
  this.toolCallMap.clear();
153
+ this.sessionRootIds.clear();
150
154
  const stack = [];
151
155
  // Determine which subtrees contain the active leaf (to sort current branch first)
152
156
  // Use iterative post-order traversal to avoid stack overflow
@@ -186,8 +190,11 @@ class TreeList {
186
190
  }
187
191
  while (stack.length > 0) {
188
192
  const [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] = stack.pop();
189
- // Extract tool calls from assistant messages for later lookup
193
+ // Extract structural roots and tool calls from messages for later lookup.
190
194
  const entry = node.entry;
195
+ if (entry.type === "message" && entry.message.role === "system") {
196
+ this.sessionRootIds.add(entry.id);
197
+ }
191
198
  if (entry.type === "message" && entry.message.role === "assistant") {
192
199
  const content = entry.message.content;
193
200
  if (Array.isArray(content)) {
@@ -266,7 +273,7 @@ class TreeList {
266
273
  const searchTokens = this.searchQuery.toLowerCase().split(/\s+/).filter(Boolean);
267
274
  this.filteredNodes = this.flatNodes.filter((flatNode) => {
268
275
  const entry = flatNode.node.entry;
269
- if (entry.type === "usage")
276
+ if (entry.type === "usage" || entry.type === "model_change" || entry.type === "thinking_level_change")
270
277
  return false;
271
278
  const isCurrentLeaf = entry.id === this.currentLeafId;
272
279
  // Skip assistant messages with only tool calls (no text) unless error/aborted
@@ -286,8 +293,6 @@ class TreeList {
286
293
  const isSettingsEntry = entry.type === "label" ||
287
294
  entry.type === "context_edit" ||
288
295
  entry.type === "custom" ||
289
- entry.type === "model_change" ||
290
- entry.type === "thinking_level_change" ||
291
296
  entry.type === "session_info";
292
297
  switch (this.filterMode) {
293
298
  case "user-only":
@@ -303,11 +308,8 @@ class TreeList {
303
308
  passesFilter = flatNode.node.label !== undefined;
304
309
  break;
305
310
  case "all":
306
- // Show everything
307
- passesFilter = true;
308
- break;
309
311
  default:
310
- // Default mode: hide settings/bookkeeping entries
312
+ // Product "all" omits internal settings/bookkeeping entries.
311
313
  passesFilter = !isSettingsEntry;
312
314
  break;
313
315
  }
@@ -523,6 +525,9 @@ class TreeList {
523
525
  getSearchQuery() {
524
526
  return this.searchQuery;
525
527
  }
528
+ getFilterMode() {
529
+ return this.filterMode;
530
+ }
526
531
  getSelectedNode() {
527
532
  return this.filteredNodes[this.selectedIndex]?.node;
528
533
  }
@@ -546,31 +551,15 @@ class TreeList {
546
551
  }
547
552
  }
548
553
  getStatusLabels() {
549
- let labels = "";
550
- switch (this.filterMode) {
551
- case "no-tools":
552
- labels += " [no-tools]";
553
- break;
554
- case "user-only":
555
- labels += " [user]";
556
- break;
557
- case "labeled-only":
558
- labels += " [labeled]";
559
- break;
560
- case "all":
561
- labels += " [all]";
562
- break;
563
- }
564
- if (this.showLabelTimestamps) {
565
- labels += " [+label time]";
566
- }
567
- return labels;
554
+ return this.showLabelTimestamps ? " label time" : "";
568
555
  }
569
556
  render(width) {
570
557
  const lines = [];
571
558
  if (this.filteredNodes.length === 0) {
572
559
  lines.push(truncateToWidth(theme.fg("muted", " No entries found"), width));
573
- lines.push(truncateToWidth(theme.fg("muted", ` (0/0)${this.getStatusLabels()}`), width));
560
+ const status = this.getStatusLabels().trim();
561
+ if (status)
562
+ lines.push(truncateToWidth(theme.fg("muted", ` ${status}`), width));
574
563
  return lines;
575
564
  }
576
565
  const startIndex = Math.max(0, Math.min(this.selectedIndex - Math.floor(this.maxVisibleLines / 2), this.filteredNodes.length - this.maxVisibleLines));
@@ -580,8 +569,8 @@ class TreeList {
580
569
  const flatNode = this.filteredNodes[i];
581
570
  const entry = flatNode.node.entry;
582
571
  const isSelected = i === this.selectedIndex;
583
- // Build line: cursor + prefix + path marker + label + content
584
- const cursor = isSelected ? theme.fg("accent", "› ") : " ";
572
+ // Build line: menu arrow + tree prefix + path marker + label + content
573
+ const cursor = isSelected ? theme.fg("accent", "→ ") : " ";
585
574
  // If multiple roots, shift display (roots at 0, not 1)
586
575
  const displayIndent = this.multipleRoots ? Math.max(0, flatNode.indent - 1) : flatNode.indent;
587
576
  // Build prefix with gutters at their correct positions
@@ -626,15 +615,12 @@ class TreeList {
626
615
  // Fold marker for nodes without connectors (roots)
627
616
  const showsFoldInConnector = flatNode.showConnector && !flatNode.isVirtualRootChild;
628
617
  const foldMarker = isFolded && !showsFoldInConnector ? theme.fg("accent", "⊞ ") : "";
629
- // Active path marker - shown right before the entry text
630
- const isOnActivePath = this.activePathIds.has(entry.id);
631
- const pathMarker = isOnActivePath ? theme.fg("accent", "• ") : "";
632
- const label = flatNode.node.label ? theme.fg("warning", `[${flatNode.node.label}] `) : "";
618
+ const label = flatNode.node.label ? theme.fg("accent", `[${flatNode.node.label}] `) : "";
633
619
  const labelTimestamp = this.showLabelTimestamps && flatNode.node.label && flatNode.node.labelTimestamp
634
- ? theme.fg("muted", `${this.formatLabelTimestamp(flatNode.node.labelTimestamp)} `)
620
+ ? theme.fg("accent", `[${this.formatLabelTimestamp(flatNode.node.labelTimestamp)}] `)
635
621
  : "";
636
- const content = this.getEntryDisplayText(flatNode.node, isSelected);
637
- const prefixPart = theme.fg("dim", prefix) + foldMarker + pathMarker;
622
+ const content = this.getEntryDisplayText(flatNode.node);
623
+ const prefixPart = theme.fg("dim", prefix) + foldMarker;
638
624
  const anchorCol = visibleWidth(prefixPart);
639
625
  let gutter = cursor;
640
626
  let body = prefixPart + label + labelTimestamp + content;
@@ -642,58 +628,53 @@ class TreeList {
642
628
  gutter = theme.bg("selectedBg", gutter);
643
629
  body = theme.bg("selectedBg", body);
644
630
  }
645
- renderedRows.push({ gutter, body, anchorCol, bodyWidth: visibleWidth(body), isSelected });
631
+ const rightClipSuffix = entry.type === "message" && entry.message.role === "toolResult" ? "]" : "";
632
+ renderedRows.push({ gutter, body, anchorCol, bodyWidth: visibleWidth(body), rightClipSuffix, isSelected });
646
633
  }
647
634
  lines.push(...renderHorizontalViewport(renderedRows, width));
648
635
  lines.push(truncateToWidth(theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredNodes.length})${this.getStatusLabels()}`), width));
649
636
  return lines;
650
637
  }
651
- getEntryDisplayText(node, isSelected) {
638
+ getEntryDisplayText(node) {
652
639
  const entry = node.entry;
653
640
  let result;
654
641
  const normalize = (s) => s.replace(/[\n\t]/g, " ").trim();
642
+ const primary = (color, text) => theme.fg(color, text);
643
+ const description = (text, color = "muted") => theme.fg(color, text);
655
644
  switch (entry.type) {
656
645
  case "message": {
657
646
  const msg = entry.message;
658
647
  const role = msg.role;
659
648
  if (role === "user") {
660
- const msgWithContent = msg;
661
- const content = normalize(this.extractContent(msgWithContent.content));
662
- result = theme.fg("accent", "user: ") + content;
649
+ const content = normalize(this.extractContent(msg.content));
650
+ result = primary("success", "user: ") + description(content);
663
651
  }
664
652
  else if (role === "assistant") {
665
- const msgWithContent = msg;
666
- const textContent = normalize(this.extractContent(msgWithContent.content));
667
- if (textContent) {
668
- result = theme.fg("success", "assistant: ") + textContent;
669
- }
670
- else if (msgWithContent.stopReason === "aborted") {
671
- result = theme.fg("success", "assistant: ") + theme.fg("muted", "(aborted)");
672
- }
673
- else if (msgWithContent.errorMessage) {
674
- const errMsg = normalize(msgWithContent.errorMessage).slice(0, 80);
675
- result = theme.fg("success", "assistant: ") + theme.fg("error", errMsg);
676
- }
677
- else {
678
- result = theme.fg("success", "assistant: ") + theme.fg("muted", "(no content)");
679
- }
653
+ const assistant = msg;
654
+ const textContent = normalize(this.extractContent(assistant.content));
655
+ const label = primary("warning", "assistant: ");
656
+ if (textContent)
657
+ result = label + description(textContent);
658
+ else if (assistant.stopReason === "aborted")
659
+ result = label + description("(aborted)", "muted");
660
+ else if (assistant.errorMessage)
661
+ result = label + description(normalize(assistant.errorMessage).slice(0, 80), "error");
662
+ else
663
+ result = label + description("(no content)", "muted");
680
664
  }
681
665
  else if (role === "toolResult") {
682
666
  const toolMsg = msg;
683
667
  const toolCall = toolMsg.toolCallId ? this.toolCallMap.get(toolMsg.toolCallId) : undefined;
684
- if (toolCall) {
685
- result = theme.fg("muted", this.formatToolCall(toolCall.name, toolCall.arguments));
686
- }
687
- else {
688
- result = theme.fg("muted", `[${toolMsg.toolName ?? "tool"}]`);
689
- }
668
+ result = primary("muted", toolCall
669
+ ? this.formatToolCall(toolCall.name, toolCall.arguments)
670
+ : `[${toolMsg.toolName ?? "tool"}]`);
690
671
  }
691
672
  else if (role === "bashExecution") {
692
- const bashMsg = msg;
693
- result = theme.fg("dim", `[bash]: ${normalize(bashMsg.command ?? "")}`);
673
+ const command = normalize(msg.command ?? "");
674
+ result = primary("dim", "[bash]: ") + description(command, "dim");
694
675
  }
695
676
  else {
696
- result = theme.fg("dim", `[${role}]`);
677
+ result = primary("dim", role === "system" ? "session" : `[${role}]`);
697
678
  }
698
679
  break;
699
680
  }
@@ -704,41 +685,41 @@ class TreeList {
704
685
  .filter((c) => c.type === "text")
705
686
  .map((c) => c.text)
706
687
  .join("");
707
- result = theme.fg("customMessageLabel", `[${entry.customType}]: `) + normalize(content);
688
+ result = primary("customMessageLabel", `[${entry.customType}]: `) + description(normalize(content));
708
689
  break;
709
690
  }
710
691
  case "compaction": {
711
692
  const tokens = Math.round(entry.tokensBefore / 1000);
712
- result = theme.fg("borderAccent", `[compaction: ${tokens}k tokens]`);
693
+ result = primary("borderAccent", `[compaction: ${tokens}k tokens]`);
713
694
  break;
714
695
  }
715
696
  case "branch_summary":
716
- result = theme.fg("warning", `[branch summary]: `) + normalize(entry.summary);
697
+ result = primary("warning", "[branch summary]: ") + description(normalize(entry.summary));
717
698
  break;
718
699
  case "model_change":
719
- result = theme.fg("dim", `[model: ${entry.modelId}]`);
700
+ result = primary("dim", `[model: ${entry.modelId}]`);
720
701
  break;
721
702
  case "thinking_level_change":
722
- result = theme.fg("dim", `[thinking: ${entry.thinkingLevel}]`);
703
+ result = primary("dim", `[thinking: ${entry.thinkingLevel}]`);
723
704
  break;
724
705
  case "custom":
725
- result = theme.fg("dim", `[custom: ${entry.customType}]`);
706
+ result = primary("dim", `[custom: ${entry.customType}]`);
726
707
  break;
727
708
  case "context_edit":
728
- result = theme.fg("dim", `[context ${entry.replacement === null ? "omit" : "replace"}: ${entry.targetId}]`);
709
+ result = primary("dim", `[context ${entry.replacement === null ? "omit" : "replace"}: ${entry.targetId}]`);
729
710
  break;
730
711
  case "label":
731
- result = theme.fg("dim", `[label: ${entry.label ?? "(cleared)"}]`);
712
+ result = primary("dim", `[label: ${entry.label ?? "(cleared)"}]`);
732
713
  break;
733
714
  case "session_info":
734
715
  result = entry.name
735
- ? [theme.fg("dim", "[title: "), theme.fg("dim", entry.name), theme.fg("dim", "]")].join("")
716
+ ? primary("dim", `[title: ${entry.name}]`)
736
717
  : [theme.fg("dim", "[title: "), theme.italic(theme.fg("dim", "empty")), theme.fg("dim", "]")].join("");
737
718
  break;
738
719
  default:
739
720
  result = "";
740
721
  }
741
- return isSelected ? theme.bold(result) : result;
722
+ return result;
742
723
  }
743
724
  formatLabelTimestamp(timestamp) {
744
725
  const date = new Date(timestamp);
@@ -850,7 +831,7 @@ class TreeList {
850
831
  .replace(/[\n\t]/g, " ")
851
832
  .trim()
852
833
  .slice(0, 50);
853
- return `[bash: ${cmd}${rawCmd.length > 50 ? "..." : ""}]`;
834
+ return `[bash: ${cmd}${rawCmd.length > 50 ? "…" : ""}]`;
854
835
  }
855
836
  case "grep": {
856
837
  const pattern = String(args.pattern || "");
@@ -869,7 +850,7 @@ class TreeList {
869
850
  default: {
870
851
  // Custom tool - show name and truncated JSON args
871
852
  const argsStr = JSON.stringify(args).slice(0, 40);
872
- return `[${name}: ${argsStr}${JSON.stringify(args).length > 40 ? "..." : ""}]`;
853
+ return `[${name}: ${argsStr}${JSON.stringify(args).length > 40 ? "…" : ""}]`;
873
854
  }
874
855
  }
875
856
  }
@@ -881,6 +862,21 @@ class TreeList {
881
862
  else if (kb.matches(keyData, "tui.select.down")) {
882
863
  this.selectedIndex = this.selectedIndex === this.filteredNodes.length - 1 ? 0 : this.selectedIndex + 1;
883
864
  }
865
+ else if (kb.matches(keyData, "owned.tree.collapse")) {
866
+ const currentId = this.filteredNodes[this.selectedIndex]?.node.entry.id;
867
+ const branchId = currentId ? this.findNearestExpandedBranch(currentId) : null;
868
+ if (branchId) {
869
+ this.foldedNodes.add(branchId);
870
+ this.applyFilter();
871
+ }
872
+ }
873
+ else if (kb.matches(keyData, "owned.tree.expand")) {
874
+ const currentId = this.filteredNodes[this.selectedIndex]?.node.entry.id;
875
+ if (currentId && this.foldedNodes.has(currentId)) {
876
+ this.foldedNodes.delete(currentId);
877
+ this.applyFilter();
878
+ }
879
+ }
884
880
  else if (kb.matches(keyData, "app.tree.foldOrUp")) {
885
881
  const currentId = this.filteredNodes[this.selectedIndex]?.node.entry.id;
886
882
  if (currentId && this.isFoldable(currentId) && !this.foldedNodes.has(currentId)) {
@@ -901,14 +897,18 @@ class TreeList {
901
897
  this.selectedIndex = this.findBranchSegmentStart("down");
902
898
  }
903
899
  }
904
- else if (kb.matches(keyData, "tui.editor.cursorLeft") || kb.matches(keyData, "tui.select.pageUp")) {
905
- // Page up
900
+ else if (kb.matches(keyData, "tui.select.pageUp")) {
906
901
  this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisibleLines);
907
902
  }
908
- else if (kb.matches(keyData, "tui.editor.cursorRight") || kb.matches(keyData, "tui.select.pageDown")) {
909
- // Page down
903
+ else if (kb.matches(keyData, "tui.select.pageDown")) {
910
904
  this.selectedIndex = Math.min(this.filteredNodes.length - 1, this.selectedIndex + this.maxVisibleLines);
911
905
  }
906
+ else if (kb.matches(keyData, "owned.tree.first")) {
907
+ this.selectedIndex = 0;
908
+ }
909
+ else if (kb.matches(keyData, "owned.tree.last")) {
910
+ this.selectedIndex = Math.max(0, this.filteredNodes.length - 1);
911
+ }
912
912
  else if (kb.matches(keyData, "tui.select.confirm")) {
913
913
  const selected = this.filteredNodes[this.selectedIndex];
914
914
  if (selected && this.onSelect) {
@@ -929,48 +929,42 @@ class TreeList {
929
929
  }
930
930
  }
931
931
  else if (kb.matches(keyData, "app.tree.filter.default")) {
932
- // Direct filter: default
933
- this.filterMode = "default";
932
+ this.filterMode = "all";
934
933
  this.foldedNodes.clear();
935
934
  this.applyFilter();
936
935
  }
937
936
  else if (kb.matches(keyData, "app.tree.filter.noTools")) {
938
- // Toggle filter: no-tools ↔ default
939
- this.filterMode = this.filterMode === "no-tools" ? "default" : "no-tools";
937
+ // Toggle filter: no-tools ↔ all
938
+ this.filterMode = this.filterMode === "no-tools" ? "all" : "no-tools";
940
939
  this.foldedNodes.clear();
941
940
  this.applyFilter();
942
941
  }
943
942
  else if (kb.matches(keyData, "app.tree.filter.userOnly")) {
944
- // Toggle filter: user-only ↔ default
945
- this.filterMode = this.filterMode === "user-only" ? "default" : "user-only";
943
+ // Toggle filter: user-only ↔ all
944
+ this.filterMode = this.filterMode === "user-only" ? "all" : "user-only";
946
945
  this.foldedNodes.clear();
947
946
  this.applyFilter();
948
947
  }
949
948
  else if (kb.matches(keyData, "app.tree.filter.labeledOnly")) {
950
- // Toggle filter: labeled-only ↔ default
951
- this.filterMode = this.filterMode === "labeled-only" ? "default" : "labeled-only";
949
+ // Toggle filter: labeled-only ↔ all
950
+ this.filterMode = this.filterMode === "labeled-only" ? "all" : "labeled-only";
952
951
  this.foldedNodes.clear();
953
952
  this.applyFilter();
954
953
  }
955
954
  else if (kb.matches(keyData, "app.tree.filter.all")) {
956
- // Toggle filter: all ↔ default
957
- this.filterMode = this.filterMode === "all" ? "default" : "all";
955
+ this.filterMode = "all";
958
956
  this.foldedNodes.clear();
959
957
  this.applyFilter();
960
958
  }
961
- else if (kb.matches(keyData, "app.tree.filter.cycleBackward")) {
962
- // Cycle filter backwards
963
- const modes = ["default", "no-tools", "user-only", "labeled-only", "all"];
964
- const currentIndex = modes.indexOf(this.filterMode);
965
- this.filterMode = modes[(currentIndex - 1 + modes.length) % modes.length];
959
+ else if (matchesKey(keyData, "shift+tab") || kb.matches(keyData, "app.tree.filter.cycleBackward")) {
960
+ const currentIndex = FILTER_MODES.indexOf(this.filterMode);
961
+ this.filterMode = FILTER_MODES[(currentIndex - 1 + FILTER_MODES.length) % FILTER_MODES.length];
966
962
  this.foldedNodes.clear();
967
963
  this.applyFilter();
968
964
  }
969
965
  else if (kb.matches(keyData, "app.tree.filter.cycleForward")) {
970
- // Cycle filter forwards: default → no-tools → user-only → labeled-only → all → default
971
- const modes = ["default", "no-tools", "user-only", "labeled-only", "all"];
972
- const currentIndex = modes.indexOf(this.filterMode);
973
- this.filterMode = modes[(currentIndex + 1) % modes.length];
966
+ const currentIndex = FILTER_MODES.indexOf(this.filterMode);
967
+ this.filterMode = FILTER_MODES[(currentIndex + 1) % FILTER_MODES.length];
974
968
  this.foldedNodes.clear();
975
969
  this.applyFilter();
976
970
  }
@@ -1003,11 +997,14 @@ class TreeList {
1003
997
  }
1004
998
  }
1005
999
  /**
1006
- * Whether a node can be folded. A node is foldable if it has visible children
1007
- * and is either a root (no visible parent) or a segment start (visible parent
1008
- * has multiple visible children).
1000
+ * Whether a node can be folded. The system entry rendered as the session root
1001
+ * stays expanded even when hidden metadata precedes it; other nodes are
1002
+ * foldable when they have visible children and either appear at the filtered
1003
+ * root or begin a segment below a visible branch point.
1009
1004
  */
1010
1005
  isFoldable(entryId) {
1006
+ if (this.sessionRootIds.has(entryId))
1007
+ return false;
1011
1008
  const children = this.visibleChildrenMap.get(entryId);
1012
1009
  if (!children || children.length === 0)
1013
1010
  return false;
@@ -1017,13 +1014,17 @@ class TreeList {
1017
1014
  const siblings = this.visibleChildrenMap.get(parentId);
1018
1015
  return siblings !== undefined && siblings.length > 1;
1019
1016
  }
1020
- /**
1021
- * Find the index of the next branch segment start in the given direction.
1022
- * A segment start is the first child of a branch point.
1023
- *
1024
- * "up" walks the visible parent chain; "down" walks visible children
1025
- * (always following the first child).
1026
- */
1017
+ /** Resolve the nearest expanded branch containing the selected entry. */
1018
+ findNearestExpandedBranch(entryId) {
1019
+ let currentId = entryId;
1020
+ while (currentId !== null) {
1021
+ if (this.isFoldable(currentId) && !this.foldedNodes.has(currentId))
1022
+ return currentId;
1023
+ currentId = this.visibleParentMap.get(currentId) ?? null;
1024
+ }
1025
+ return null;
1026
+ }
1027
+ /** Find the next branch segment start for the retained modified-arrow shortcuts. */
1027
1028
  findBranchSegmentStart(direction) {
1028
1029
  const selectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id;
1029
1030
  if (!selectedId)
@@ -1040,7 +1041,6 @@ class TreeList {
1040
1041
  currentId = children[0];
1041
1042
  }
1042
1043
  }
1043
- // direction === "up"
1044
1044
  while (true) {
1045
1045
  const parentId = this.visibleParentMap.get(currentId) ?? null;
1046
1046
  if (parentId === null)
@@ -1048,38 +1048,63 @@ class TreeList {
1048
1048
  const children = this.visibleChildrenMap.get(parentId) ?? [];
1049
1049
  if (children.length > 1) {
1050
1050
  const segmentStart = indexByEntryId.get(currentId);
1051
- if (segmentStart < this.selectedIndex) {
1051
+ if (segmentStart < this.selectedIndex)
1052
1052
  return segmentStart;
1053
- }
1054
1053
  }
1055
1054
  currentId = parentId;
1056
1055
  }
1057
1056
  }
1058
1057
  }
1059
- /** Component that displays the current search query */
1060
- class SearchLine {
1058
+ class TreeSearchInput {
1061
1059
  treeList;
1060
+ input = new Input();
1061
+ renderedQuery = "";
1062
1062
  constructor(treeList) {
1063
1063
  this.treeList = treeList;
1064
1064
  }
1065
- invalidate() { }
1065
+ get focused() {
1066
+ return this.input.focused;
1067
+ }
1068
+ set focused(value) {
1069
+ this.input.focused = value;
1070
+ }
1071
+ invalidate() {
1072
+ this.input.invalidate();
1073
+ }
1066
1074
  render(width) {
1067
1075
  const query = this.treeList.getSearchQuery();
1068
- if (query) {
1069
- return [truncateToWidth(` ${theme.fg("muted", "Type to search:")} ${theme.fg("accent", query)}`, width)];
1076
+ if (query !== this.renderedQuery) {
1077
+ this.input = Object.assign(new Input(), { focused: this.input.focused });
1078
+ this.input.handleInput(query);
1079
+ this.renderedQuery = query;
1070
1080
  }
1071
- return [truncateToWidth(` ${theme.fg("muted", "Type to search:")}`, width)];
1081
+ return this.input.render(width);
1082
+ }
1083
+ }
1084
+ /** Models-style summary of the active tree filter. */
1085
+ class TreeFilter {
1086
+ treeList;
1087
+ constructor(treeList) {
1088
+ this.treeList = treeList;
1089
+ }
1090
+ invalidate() { }
1091
+ render(width) {
1092
+ const active = this.treeList.getFilterMode();
1093
+ const choices = FILTER_MODES.map((mode) => theme.fg(mode === active ? "accent" : "muted", FILTER_LABELS[mode]));
1094
+ return [truncateToWidth(theme.fg("muted", "Filter: ") + choices.join(theme.fg("muted", " | ")), width)];
1072
1095
  }
1073
- handleInput(_keyData) { }
1074
1096
  }
1075
1097
  /** Component that renders tree help as semantic rows with chunk-aware wrapping */
1076
1098
  class TreeHelp {
1077
1099
  invalidate() { }
1078
1100
  render(width) {
1079
- const items = TREE_HELP_ITEMS.map(({ keys, label, labelFirst }) => {
1080
- const key = formatHelpKeys(keys);
1081
- return renderPiModalShortcutHints([key ? { key, action: label, ...(labelFirst === undefined ? {} : { actionFirst: labelFirst }) } : { action: label }]);
1082
- });
1101
+ const items = [
1102
+ ...TREE_HELP_ITEMS.map(({ keys, label, labelFirst }) => {
1103
+ const key = formatHelpKeys(keys);
1104
+ return renderPiModalShortcutHints([key ? { key, action: label, ...(labelFirst === undefined ? {} : { actionFirst: labelFirst }) } : { action: label }]);
1105
+ }),
1106
+ renderPiModalShortcutHints([DIALOG_CLOSE_SHORTCUT_HINT]),
1107
+ ];
1083
1108
  const availableWidth = Math.max(1, width);
1084
1109
  // The frame supplies the shared outer cell; help content needs no duplicate chrome inset.
1085
1110
  const indent = "";
@@ -1106,24 +1131,15 @@ class TreeHelp {
1106
1131
  }
1107
1132
  }
1108
1133
  const TREE_HELP_ITEMS = [
1109
- { keys: ["tui.select.up", "tui.select.down"], label: "move" },
1110
- { keys: ["tui.editor.cursorLeft", "tui.editor.cursorRight"], label: "page" },
1111
- { keys: ["app.tree.foldOrUp", "app.tree.unfoldOrDown"], label: "branch" },
1134
+ { keys: [], label: "type to search" },
1135
+ { keys: ["tui.select.up", "tui.select.down"], label: "navigate" },
1136
+ { keys: ["app.tree.filter.cycleForward"], label: "filter" },
1137
+ { keys: ["tui.select.pageUp", "tui.select.pageDown"], label: "page" },
1138
+ { keys: ["owned.tree.first", "owned.tree.last"], label: "first/last" },
1139
+ { keys: ["owned.tree.collapse", "owned.tree.expand"], label: "branch" },
1112
1140
  { keys: ["app.message.copy"], label: "copy" },
1113
1141
  { keys: ["app.tree.editLabel"], label: "label" },
1114
1142
  { keys: ["app.tree.toggleLabelTimestamp"], label: "label time" },
1115
- {
1116
- keys: [
1117
- "app.tree.filter.default",
1118
- "app.tree.filter.noTools",
1119
- "app.tree.filter.userOnly",
1120
- "app.tree.filter.labeledOnly",
1121
- "app.tree.filter.all",
1122
- ],
1123
- label: "filters",
1124
- labelFirst: true,
1125
- },
1126
- { keys: ["app.tree.filter.cycleForward", "app.tree.filter.cycleBackward"], label: "cycle", labelFirst: true },
1127
1143
  ];
1128
1144
  function formatHelpKeys(keybindings) {
1129
1145
  const keys = [];
@@ -1135,6 +1151,7 @@ function formatHelpKeys(keybindings) {
1135
1151
  if (keys.length === 0)
1136
1152
  return "";
1137
1153
  return formatKeyText(compactRawKeys(keys))
1154
+ .replace(/\btab\b/gi, "Tab")
1138
1155
  .replace(/\bpageUp\b/g, "pgup")
1139
1156
  .replace(/\bpageDown\b/g, "pgdn")
1140
1157
  .replace(/\bup\b/g, "↑")
@@ -1174,22 +1191,23 @@ class LabelInput {
1174
1191
  constructor(entryId, currentLabel) {
1175
1192
  this.entryId = entryId;
1176
1193
  this.input = new Input();
1177
- if (currentLabel) {
1178
- this.input.setValue(currentLabel);
1179
- }
1194
+ if (currentLabel)
1195
+ this.input.handleInput(currentLabel);
1196
+ }
1197
+ invalidate() {
1198
+ this.input.invalidate();
1180
1199
  }
1181
- invalidate() { }
1182
1200
  render(width) {
1183
- const lines = [];
1184
- const indent = " ";
1185
- const availableWidth = width - indent.length;
1186
- lines.push(truncateToWidth(`${indent}${theme.fg("muted", "Label (empty to remove):")}`, width));
1187
- lines.push(...this.input.render(availableWidth).map((line) => truncateToWidth(`${indent}${line}`, width)));
1188
- lines.push(truncateToWidth(renderPiModalShortcutHints([
1189
- shortcutHint("tui.select.confirm", "save"),
1190
- shortcutHint("tui.select.cancel", "cancel"),
1191
- ], indent.length), width));
1192
- return lines;
1201
+ return [
1202
+ truncateToWidth(theme.fg("muted", "Empty to remove"), width),
1203
+ "",
1204
+ ...this.input.render(width),
1205
+ "",
1206
+ truncateToWidth(renderPiModalShortcutHints([
1207
+ shortcutHint("tui.select.confirm", "save"),
1208
+ DIALOG_CLOSE_SHORTCUT_HINT,
1209
+ ]), width),
1210
+ ];
1193
1211
  }
1194
1212
  handleInput(keyData) {
1195
1213
  const kb = treeKeybindings;
@@ -1210,9 +1228,15 @@ class LabelInput {
1210
1228
  */
1211
1229
  export class TreeSelectorComponent extends Container {
1212
1230
  treeList;
1231
+ searchInput;
1232
+ treeFilter;
1233
+ titleText;
1234
+ searchInputContainer = new Container();
1235
+ treeContainer = new Container();
1236
+ labelInputContainer = new Container();
1237
+ footerContainer = new Container();
1238
+ treeHelp = new TreeHelp();
1213
1239
  labelInput = null;
1214
- labelInputContainer;
1215
- treeContainer;
1216
1240
  onLabelChangeCallback;
1217
1241
  onCopy;
1218
1242
  // Focusable implementation - propagate to labelInput when active for IME cursor positioning
@@ -1222,10 +1246,9 @@ export class TreeSelectorComponent extends Container {
1222
1246
  }
1223
1247
  set focused(value) {
1224
1248
  this._focused = value;
1225
- // Propagate to labelInput when it's active
1226
- if (this.labelInput) {
1249
+ this.searchInput.focused = value && this.labelInput === null;
1250
+ if (this.labelInput)
1227
1251
  this.labelInput.focused = value;
1228
- }
1229
1252
  }
1230
1253
  constructor(tree, currentLeafId, terminalHeight, onSelect, onCancel, onLabelChange, initialSelectedId, initialFilterMode) {
1231
1254
  super();
@@ -1236,30 +1259,38 @@ export class TreeSelectorComponent extends Container {
1236
1259
  this.treeList.onCancel = onCancel;
1237
1260
  this.treeList.onCopy = (text) => this.onCopy?.(text);
1238
1261
  this.treeList.onLabelEdit = (entryId, currentLabel) => this.showLabelInput(entryId, currentLabel);
1239
- this.treeContainer = new Container();
1240
- this.treeContainer.addChild(this.treeList);
1241
- this.labelInputContainer = new Container();
1242
- this.addChild(new Spacer(1));
1243
- const header = addPiModalHeader(this, new DynamicBorder(), new Text(theme.bold("Session Tree"), 0, 0));
1244
- this.addChild(new TreeHelp());
1245
- this.addChild(new SearchLine(this.treeList));
1246
- const separator = new DynamicBorder();
1247
- this.addChild(separator);
1248
- this.addChild(new Spacer(1));
1262
+ this.searchInput = new TreeSearchInput(this.treeList);
1263
+ this.treeFilter = new TreeFilter(this.treeList);
1264
+ this.titleText = new Text(theme.fg("accent", theme.bold("Session Tree")), 0, 0);
1265
+ this.restoreTreeContent();
1266
+ const header = addPiModalHeader(this, new DynamicBorder(), this.titleText);
1267
+ this.addChild(this.searchInputContainer);
1249
1268
  this.addChild(this.treeContainer);
1250
1269
  this.addChild(this.labelInputContainer);
1251
- this.addChild(new Spacer(1));
1270
+ this.addChild(this.footerContainer);
1252
1271
  this.addChild(new DynamicBorder());
1253
1272
  adoptPiModalFrame(this, {
1254
- topIndex: 1,
1273
+ topIndex: 0,
1255
1274
  bottomIndex: this.children.length - 1,
1256
1275
  header,
1257
- fullWidthContent: [separator],
1258
1276
  });
1259
1277
  if (tree.length === 0) {
1260
1278
  setTimeout(() => onCancel(), 100);
1261
1279
  }
1262
1280
  }
1281
+ restoreTreeContent() {
1282
+ this.titleText.setText(theme.fg("accent", theme.bold("Session Tree")));
1283
+ this.searchInputContainer.clear();
1284
+ this.searchInputContainer.addChild(this.treeFilter);
1285
+ this.searchInputContainer.addChild(new Spacer(1));
1286
+ this.searchInputContainer.addChild(this.searchInput);
1287
+ this.searchInputContainer.addChild(new Spacer(1));
1288
+ this.treeContainer.clear();
1289
+ this.treeContainer.addChild(this.treeList);
1290
+ this.footerContainer.clear();
1291
+ this.footerContainer.addChild(new Spacer(1));
1292
+ this.footerContainer.addChild(this.treeHelp);
1293
+ }
1263
1294
  showLabelInput(entryId, currentLabel) {
1264
1295
  this.labelInput = new LabelInput(entryId, currentLabel);
1265
1296
  this.labelInput.onSubmit = (id, label) => {
@@ -1268,17 +1299,21 @@ export class TreeSelectorComponent extends Container {
1268
1299
  this.hideLabelInput();
1269
1300
  };
1270
1301
  this.labelInput.onCancel = () => this.hideLabelInput();
1271
- // Propagate current focused state to the new labelInput
1302
+ // Focus only the active editor.
1303
+ this.searchInput.focused = false;
1272
1304
  this.labelInput.focused = this._focused;
1305
+ this.titleText.setText(theme.fg("accent", theme.bold("Label")));
1306
+ this.searchInputContainer.clear();
1273
1307
  this.treeContainer.clear();
1274
1308
  this.labelInputContainer.clear();
1275
1309
  this.labelInputContainer.addChild(this.labelInput);
1310
+ this.footerContainer.clear();
1276
1311
  }
1277
1312
  hideLabelInput() {
1278
1313
  this.labelInput = null;
1314
+ this.searchInput.focused = this._focused;
1279
1315
  this.labelInputContainer.clear();
1280
- this.treeContainer.clear();
1281
- this.treeContainer.addChild(this.treeList);
1316
+ this.restoreTreeContent();
1282
1317
  }
1283
1318
  handleInput(keyData) {
1284
1319
  if (this.labelInput) {