@visual-z/xeno 0.1.4 → 0.2.1

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 (147) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +143 -33
  3. package/docs/install.md +23 -6
  4. package/docs/providers.md +147 -0
  5. package/package.json +8 -2
  6. package/src/apps/cli.ts +45 -18
  7. package/src/apps/commands.ts +32 -4
  8. package/src/apps/completions.ts +9 -6
  9. package/src/apps/export-cli.ts +24 -0
  10. package/src/apps/extension-ui.ts +330 -0
  11. package/src/apps/input.ts +16 -1
  12. package/src/apps/model-catalog.ts +42 -0
  13. package/src/apps/project-trust.ts +49 -0
  14. package/src/apps/render.ts +1 -0
  15. package/src/apps/resource-browser.ts +75 -0
  16. package/src/apps/resources.ts +25 -0
  17. package/src/apps/session-browser.ts +113 -0
  18. package/src/apps/session-picker.ts +55 -0
  19. package/src/apps/shell.ts +1177 -378
  20. package/src/apps/startup-input.ts +94 -0
  21. package/src/apps/startup.ts +413 -295
  22. package/src/core/action-defaults.ts +306 -0
  23. package/src/core/attachment-input.ts +17 -0
  24. package/src/core/catalog-merge.ts +19 -0
  25. package/src/core/event-validation.ts +19 -2
  26. package/src/core/events.ts +45 -2
  27. package/src/core/export.ts +6 -2
  28. package/src/core/extension-flags.ts +14 -0
  29. package/src/core/images.ts +46 -0
  30. package/src/core/json-event.ts +2 -0
  31. package/src/core/keymap.ts +43 -119
  32. package/src/core/model-replay.ts +22 -0
  33. package/src/core/model-scope.ts +53 -0
  34. package/src/core/packages.ts +47 -0
  35. package/src/core/project.ts +21 -3
  36. package/src/core/prompts.ts +33 -5
  37. package/src/core/queue.ts +18 -0
  38. package/src/core/resource-selection.ts +36 -0
  39. package/src/{runtime → core}/resume.ts +15 -22
  40. package/src/core/session-archive.ts +4 -0
  41. package/src/core/session-browser.ts +82 -0
  42. package/src/core/session-entry.ts +56 -0
  43. package/src/core/session-position.ts +26 -0
  44. package/src/core/settings.ts +169 -154
  45. package/src/core/theme.ts +15 -6
  46. package/src/core/thinking.ts +26 -0
  47. package/src/core/tokens.ts +2 -0
  48. package/src/core/transcript.ts +15 -5
  49. package/src/runtime/auth.ts +116 -11
  50. package/src/runtime/catalog.ts +54 -151
  51. package/src/runtime/clipboard.ts +114 -0
  52. package/src/runtime/extension-loader.ts +37 -0
  53. package/src/runtime/extension-ui.ts +55 -0
  54. package/src/runtime/extensions.ts +273 -0
  55. package/src/runtime/images.ts +76 -0
  56. package/src/runtime/loop.ts +213 -83
  57. package/src/runtime/model-config.ts +232 -0
  58. package/src/runtime/model-data.LICENSE +27 -0
  59. package/src/runtime/model-data.ts +21 -0
  60. package/src/runtime/model-replay.ts +10 -0
  61. package/src/runtime/model-scope.ts +20 -0
  62. package/src/runtime/model-sources.ts +151 -0
  63. package/src/runtime/model.ts +74 -11
  64. package/src/runtime/models-fetch.ts +6 -104
  65. package/src/runtime/oauth.ts +4 -3
  66. package/src/runtime/openai-responses.ts +12 -4
  67. package/src/runtime/openai.ts +13 -4
  68. package/src/runtime/package-manager.ts +125 -0
  69. package/src/runtime/pi-provider.ts +235 -0
  70. package/src/runtime/progress.ts +26 -0
  71. package/src/runtime/provider-access.ts +21 -0
  72. package/src/runtime/provider-protocols.ts +29 -0
  73. package/src/runtime/provider-request.ts +73 -0
  74. package/src/runtime/provider.ts +26 -6
  75. package/src/runtime/rich-text.ts +36 -0
  76. package/src/runtime/tools.ts +41 -5
  77. package/src/store/command.ts +7 -1
  78. package/src/store/context-files.ts +1 -1
  79. package/src/store/log.ts +65 -0
  80. package/src/store/models.ts +28 -9
  81. package/src/store/observations.ts +4 -2
  82. package/src/store/packages.ts +98 -0
  83. package/src/store/recovery.ts +8 -0
  84. package/src/store/resource-index.ts +170 -0
  85. package/src/store/resources.ts +75 -142
  86. package/src/store/session-archive.ts +162 -0
  87. package/src/store/session-browser.ts +68 -0
  88. package/src/store/session-position.ts +27 -0
  89. package/src/store/sessions.ts +87 -66
  90. package/src/store/trust.ts +13 -0
  91. package/src/tui/chat-viewport.ts +47 -0
  92. package/src/tui/editor.ts +62 -15
  93. package/src/tui/host.ts +50 -9
  94. package/src/tui/input.ts +8 -2
  95. package/src/tui/select.ts +24 -6
  96. package/src/tui/settings.ts +4 -0
  97. package/src/tui/system-theme.ts +700 -0
  98. package/src/tui/transcript.ts +63 -11
  99. package/src/tui/tree.ts +161 -95
  100. package/src/vendor/pi-rich/LICENSE.upstream +21 -0
  101. package/src/vendor/pi-rich/UPSTREAM.json +17 -0
  102. package/src/vendor/pi-rich/clipboard-command.ts +44 -0
  103. package/src/vendor/pi-rich/exif-orientation.ts +182 -0
  104. package/src/vendor/pi-rich/html.ts +51 -0
  105. package/src/vendor/pi-rich/image-convert.ts +49 -0
  106. package/src/vendor/pi-rich/image-resize-core.ts +164 -0
  107. package/src/vendor/pi-rich/image-resize-worker.ts +42 -0
  108. package/src/vendor/pi-rich/image-resize.ts +123 -0
  109. package/src/vendor/pi-rich/mime.ts +116 -0
  110. package/src/vendor/pi-rich/photon.ts +139 -0
  111. package/src/vendor/pi-rich/syntax-highlight.ts +214 -0
  112. package/src/vendor/pi-tui/UPSTREAM.json +50 -0
  113. package/src/vendor/pi-tui/VENDOR.md +7 -5
  114. package/src/vendor/pi-tui/autocomplete.ts +35 -10
  115. package/src/vendor/pi-tui/colors.ts +367 -0
  116. package/src/vendor/pi-tui/components/box.ts +8 -9
  117. package/src/vendor/pi-tui/components/editor.ts +4 -2
  118. package/src/vendor/pi-tui/components/markdown.ts +8 -2
  119. package/src/vendor/pi-tui/index.ts +31 -1
  120. package/src/vendor/pi-tui/native/UPSTREAM.json +23 -0
  121. package/src/vendor/pi-tui/native/clipboard.h +208 -0
  122. package/src/vendor/pi-tui/native/darwin/README.md +22 -0
  123. package/src/vendor/pi-tui/native/darwin/build.sh +69 -0
  124. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  125. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  126. package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +132 -0
  127. package/src/vendor/pi-tui/native/linux/README.md +23 -0
  128. package/src/vendor/pi-tui/native/linux/build.sh +65 -0
  129. package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  130. package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  131. package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +70 -0
  132. package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +274 -0
  133. package/src/vendor/pi-tui/native/napi.h +91 -0
  134. package/src/vendor/pi-tui/native/win32/README.md +31 -0
  135. package/src/vendor/pi-tui/native/win32/build.mjs +232 -0
  136. package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  137. package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  138. package/src/vendor/pi-tui/native/win32/src/win32-platform.c +241 -0
  139. package/src/vendor/pi-tui/native-platform.ts +2 -0
  140. package/src/vendor/pi-tui/oklab.ts +233 -0
  141. package/src/vendor/pi-tui/terminal-colors.ts +26 -8
  142. package/src/vendor/pi-tui/terminal-image.ts +43 -8
  143. package/src/vendor/pi-tui/terminal.ts +16 -9
  144. package/src/vendor/pi-tui/tui-alt-screen.ts +26 -18
  145. package/src/vendor/pi-tui/tui.ts +112 -75
  146. package/src/vendor/pi-tui/utils.ts +89 -49
  147. package/src/vendor/pi-tui/wheel-scroll.ts +82 -0
@@ -1,14 +1,27 @@
1
- import { Container, PiText, Lines, assistantMarkdown } from "./host.ts";
1
+ import { Container, PiText, Lines, assistantMarkdown, type MarkdownFeatures } from "./host.ts";
2
+ import { Image } from "../vendor/pi-tui/components/image.ts";
2
3
  import { renderToolView } from "./toolview.ts";
3
4
  import { PLAIN_ATTRS, paint, type Style } from "./style.ts";
4
5
  import { applyBackgroundToLine } from "../vendor/pi-tui/utils.ts";
6
+ import type { Component } from "../vendor/pi-tui/tui.ts";
5
7
 
6
8
  /** Presentation DTO; the application supplies its pure log projection. */
9
+ type Attachment = { sha256: string; name: string; width: number; height: number };
7
10
  type Item = { id: string; revision: string } & (
8
- | { kind: "user"; text: string; interrupted: boolean }
11
+ | { kind: "user"; text: string; interrupted: boolean; images?: readonly Attachment[] }
9
12
  | { kind: "assistant" | "thinking" | "notice"; text: string }
10
- | { kind: "tool"; name: string; input: unknown; result: { text: string; ok: boolean } | null }
13
+ | { kind: "custom"; text: string; customType: string; data?: unknown; entry: boolean; timestamp: string }
14
+ | { kind: "tool"; callId: string; name: string; input: unknown; result: { text: string; ok: boolean; data?: unknown; images?: readonly Attachment[] } | null }
11
15
  );
16
+ interface ExtensionRenderers {
17
+ message(type: string, text: string, data: unknown, expanded: boolean, fallback: Component, metadata: { id: string; entry: boolean; timestamp: string }): Component;
18
+ tool(name: string, input: unknown, result: { text: string; ok: boolean; data?: unknown } | null, expanded: boolean, call: Component, output: Component, id: string): Component;
19
+ hiddenThinkingLabel: string | undefined;
20
+ }
21
+ function dispose(component: Component): void {
22
+ if (component instanceof Container) component.children.forEach(dispose);
23
+ else if ("dispose" in component && typeof component.dispose === "function") component.dispose();
24
+ }
12
25
 
13
26
  export class Transcript extends Container {
14
27
  private items: readonly Item[] = [];
@@ -16,11 +29,29 @@ export class Transcript extends Container {
16
29
  private style: Style | null = null;
17
30
  private toolsExpanded = false;
18
31
  private thinkingExpanded = false;
32
+ private padding = 1;
33
+ private features: MarkdownFeatures | undefined;
34
+ private images: ReadonlyMap<string, string> = new Map();
35
+ private showImages = true;
36
+ private imageWidth = 60;
37
+ private extensions: ExtensionRenderers | undefined;
38
+ setExtensions(renderers: ExtensionRenderers): void { this.extensions = renderers; }
39
+ private clearCache(): void { for (const entry of this.cache.values()) dispose(entry.component); this.cache.clear(); }
40
+ configure(features: MarkdownFeatures, images: ReadonlyMap<string, string>, showImages: boolean, width: number): void {
41
+ this.features = features; this.images = images; this.showImages = showImages; this.imageWidth = width;
42
+ this.clearCache(); this.items = [];
43
+ }
44
+ setPadding(padding: number): void { if (this.padding !== padding) { this.padding = padding; this.invalidate(); } }
45
+ override render(width: number): string[] {
46
+ const padding = Math.min(this.padding, Math.max(0, Math.floor((width - 1) / 2)));
47
+ return super.render(Math.max(1, width - padding * 2)).map(line => line.startsWith("\x1b]133;A\x07")
48
+ ? "\x1b]133;A\x07" + " ".repeat(padding) + line.slice(8) : " ".repeat(padding) + line);
49
+ }
19
50
 
20
51
  update(items: readonly Item[], style: Style, toolsExpanded: boolean, thinkingExpanded: boolean): void {
21
52
  const changed = style !== this.style || toolsExpanded !== this.toolsExpanded || thinkingExpanded !== this.thinkingExpanded;
22
53
  if (!changed && this.items === items) return;
23
- if (changed) this.cache.clear();
54
+ if (changed) this.clearCache();
24
55
  this.items = items;
25
56
  this.style = style;
26
57
  this.toolsExpanded = toolsExpanded;
@@ -33,6 +64,7 @@ export class Transcript extends Container {
33
64
  next.set(item.id, { revision: item.revision, component });
34
65
  this.addChild(component);
35
66
  }
67
+ for (const [id, entry] of this.cache) if (next.get(id)?.component !== entry.component) dispose(entry.component);
36
68
  this.cache = next;
37
69
  }
38
70
 
@@ -40,15 +72,20 @@ export class Transcript extends Container {
40
72
  const component = new Container();
41
73
  const dim = (text: string) => paint(text, { ...PLAIN_ATTRS, dim: true }, style);
42
74
  switch (item.kind) {
43
- case "user":
44
- component.addChild(new PiText(paint(`❯ ${item.text}`, { ...PLAIN_ATTRS, prompt: true }, style), 1, 1,
45
- (text) => style.token?.("userMessageBg", text) ?? text));
75
+ case "user": {
76
+ component.addChild(new Lines(width => {
77
+ const transformed = this.features?.transform(item.text, width, false, style, "user") ?? item.text;
78
+ const text = new PiText(paint(`❯ ${transformed}`, { ...PLAIN_ATTRS, prompt: true }, style), 0, 1, text => style.token?.("userMessageBg", text) ?? text);
79
+ return text.render(width).map((line, index) => index === 0 ? "\x1b]133;A\x07" + line : line);
80
+ }));
81
+ this.addImages(component, item.images, dim);
46
82
  if (item.interrupted) component.addChild(new PiText(dim(" interrupted"), 0, 0));
47
83
  break;
48
- case "assistant": component.addChild(assistantMarkdown(item.text, style)); break;
84
+ }
85
+ case "assistant": component.addChild(assistantMarkdown(item.text, style, this.features)); break;
49
86
  case "thinking": {
50
87
  const expanded = this.thinkingExpanded;
51
- component.addChild(new Lines((width) => renderToolView({ kind: "thinking", text: item.text }, expanded, width, style)));
88
+ component.addChild(!expanded && this.extensions?.hiddenThinkingLabel !== undefined ? new PiText(this.extensions.hiddenThinkingLabel, 0, 0) : new Lines((width) => renderToolView({ kind: "thinking", text: this.features?.transform(item.text, width, false, style, "assistant-thinking") ?? item.text }, expanded, width, style)));
52
89
  break;
53
90
  }
54
91
  case "tool": {
@@ -60,12 +97,27 @@ export class Transcript extends Container {
60
97
  const result = item.result;
61
98
  rows.push(new Lines((width) => renderToolView({ kind: "tool_result", tool, text: result.text, ok: result.ok }, expanded, width, style)));
62
99
  } else rows.push(new Lines(() => [dim(" pending")]));
63
- component.addChild(new Lines((width) => rows.flatMap((row) => row.render(width)).map((line) =>
64
- applyBackgroundToLine(line, width, (text) => style.token?.(background, text) ?? text))));
100
+ const rendered = this.extensions?.tool(item.name, item.input, item.result, expanded, rows[0]!, rows[1]!, item.callId) ?? new Lines(width => rows.flatMap(row => row.render(width)));
101
+ const toolComponent = { render: (width: number) => rendered.render(width).map(line => applyBackgroundToLine(line, width, text => style.token?.(background, text) ?? text)),
102
+ invalidate: () => rendered.invalidate(), dispose: () => dispose(rendered) };
103
+ component.addChild(toolComponent);
104
+ this.addImages(component, item.result?.images, dim);
65
105
  break;
66
106
  }
67
107
  case "notice": component.addChild(new PiText(dim(item.text), 0, 0)); break;
108
+ case "custom": {
109
+ const fallback = new PiText(dim(`[${item.customType}] ${item.text}`), 0, 0);
110
+ component.addChild(this.extensions?.message(item.customType, item.text, item.data, this.toolsExpanded, fallback, item) ?? fallback); break;
111
+ }
68
112
  }
69
113
  return component;
70
114
  }
115
+ private addImages(component: Container, images: readonly Attachment[] | undefined, dim: (text: string) => string): void {
116
+ for (const image of images ?? []) {
117
+ const data = this.images.get(image.sha256);
118
+ if (this.showImages && data) component.addChild(new Image(data, "image/png", { fallbackColor: dim },
119
+ { filename: image.name, maxWidthCells: this.imageWidth }, { widthPx: image.width, heightPx: image.height }));
120
+ else component.addChild(new PiText(dim(`[Image: ${image.name}, ${image.width}x${image.height}]`), 0, 0));
121
+ }
122
+ }
71
123
  }
package/src/tui/tree.ts CHANGED
@@ -15,8 +15,9 @@ import type { Component } from "./text.ts";
15
15
  import { displayWidth } from "./width.ts";
16
16
  import { PLAIN_ATTRS, paint, type Style } from "./style.ts";
17
17
  import { Input } from "../vendor/pi-tui/components/input.ts";
18
- import { encodeKey, inputKey, keyMatches } from "./input.ts";
18
+ import { encodeKey } from "./input.ts";
19
19
  import { truncateToWidth } from "../vendor/pi-tui/utils.ts";
20
+ import { getKeybindings } from "../vendor/pi-tui/keybindings.ts";
20
21
 
21
22
  /** Same mark /back uses for a branch the session rewound away from. */
22
23
  export const LEFT_BRANCH_MARK = "↩";
@@ -42,7 +43,7 @@ const ON_PATH_MARK = " ";
42
43
  const LEFT_MARK = `${LEFT_BRANCH_MARK} `;
43
44
 
44
45
  export type TreeKind = "user" | "other";
45
- export type TreeFilter = "user" | "all";
46
+ export type TreeFilter = "user" | "default" | "no-tools" | "user-only" | "labeled-only" | "all";
46
47
 
47
48
  export interface TreeNode {
48
49
  readonly id: string;
@@ -51,7 +52,12 @@ export interface TreeNode {
51
52
  readonly current: boolean;
52
53
  readonly kind: TreeKind;
53
54
  readonly children: readonly TreeNode[];
54
- /** Non-user rows navigate to their enclosing complete exchange. */
55
+ readonly category?: "tool" | "message" | "metadata";
56
+ readonly label?: string;
57
+ readonly labelAt?: string;
58
+ readonly at?: string;
59
+ readonly content?: string;
60
+ /** Exact event position; user messages navigate before themselves. */
55
61
  readonly target?: string;
56
62
  }
57
63
 
@@ -71,6 +77,7 @@ export interface TreeState {
71
77
  export interface CreateTreeOptions {
72
78
  readonly filter?: TreeFilter;
73
79
  readonly style?: Style;
80
+ readonly bindings?: Readonly<Record<string, readonly string[]>>;
74
81
  }
75
82
 
76
83
  /**
@@ -82,6 +89,13 @@ export interface TreePoint {
82
89
  readonly atEvent: string;
83
90
  readonly prompt: string;
84
91
  readonly onPath: boolean;
92
+ readonly kind?: TreeKind;
93
+ readonly category?: "tool" | "message" | "metadata";
94
+ readonly label?: string;
95
+ readonly labelAt?: string;
96
+ readonly at?: string;
97
+ readonly content?: string;
98
+ readonly target?: string;
85
99
  }
86
100
 
87
101
  export type NodeParent = { readonly node: number; readonly at_event?: string } | null;
@@ -131,19 +145,25 @@ export function applyTreeKey(state: TreeState, key: Key): TreeState {
131
145
  function visibleNodes(nodes: readonly TreeNode[], filter: TreeFilter): TreeNode[] {
132
146
  if (filter === "all") return [...nodes];
133
147
  const out: TreeNode[] = [];
134
- for (const n of nodes) {
135
- const children = visibleNodes(n.children, filter);
136
- if (n.kind === "user") out.push({ ...n, children });
137
- else out.push(...children);
148
+ const stack = [...nodes].reverse().map(node => ({ node, into: out }));
149
+ while (stack.length) {
150
+ const { node: n, into } = stack.pop()!;
151
+ const include = filter === "user" || filter === "user-only" ? n.kind === "user" :
152
+ filter === "labeled-only" ? Boolean(n.label) : filter === "no-tools" ? n.category !== "tool" && n.category !== "metadata" : n.category !== "metadata";
153
+ const children: TreeNode[] = [];
154
+ if (include) into.push({ ...n, children });
155
+ for (let i = n.children.length - 1; i >= 0; i--) stack.push({ node: n.children[i]!, into: include ? children : into });
138
156
  }
139
157
  return out;
140
158
  }
141
159
 
142
160
  function flatten(nodes: readonly TreeNode[]): TreeNode[] {
143
161
  const out: TreeNode[] = [];
144
- for (const n of nodes) {
162
+ const stack = [...nodes].reverse();
163
+ while (stack.length) {
164
+ const n = stack.pop()!;
145
165
  out.push(n);
146
- out.push(...flatten(n.children));
166
+ for (let i = n.children.length - 1; i >= 0; i--) stack.push(n.children[i]!);
147
167
  }
148
168
  return out;
149
169
  }
@@ -162,7 +182,7 @@ function confirm(state: TreeState): TreeState {
162
182
  }
163
183
 
164
184
  function toggleFilter(state: TreeState): TreeState {
165
- const filter: TreeFilter = state.filter === "all" ? "user" : "all";
185
+ const filter: TreeFilter = state.filter === "all" || state.filter === "default" ? "user" : "all";
166
186
  const n = flatten(visibleNodes(state.nodes, filter)).length;
167
187
  const selected = n === 0 ? 0 : Math.min(state.selected, n - 1);
168
188
  return { ...state, filter, selected, confirmed: null, cancelled: false };
@@ -219,15 +239,14 @@ function walk(
219
239
  style: Style | undefined,
220
240
  lines: string[],
221
241
  ): void {
222
- for (let i = 0; i < nodes.length; i++) {
223
- const node = nodes[i];
224
- if (node === undefined) continue;
225
- const last = i === nodes.length - 1;
226
- const branch = isRoot ? "" : last ? BRANCH_END : BRANCH_MID;
242
+ const stack = nodes.map((node, i) => ({ node, prefix, root: isRoot, last: i === nodes.length - 1 })).reverse();
243
+ while (stack.length) {
244
+ const { node, prefix: indent, root, last } = stack.pop()!;
245
+ const branch = root ? "" : last ? BRANCH_END : BRANCH_MID;
227
246
  const sel = node.id === selectedId ? SELECTED : UNSELECTED;
228
247
  const mark = node.current ? CURRENT_MARK : node.onPath ? ON_PATH_MARK : LEFT_MARK;
229
248
  // Left-to-right: connectors stay, prompt is what width drops first.
230
- const raw = sel + prefix + branch + mark + node.prompt;
249
+ const raw = sel + indent + branch + mark + (node.label ? `[${node.label}] ` : "") + node.prompt;
231
250
  const clipped = truncate(raw, width);
232
251
  const chosen = node.id === selectedId;
233
252
  lines.push(
@@ -235,8 +254,11 @@ function walk(
235
254
  ? paint(clipped, { ...PLAIN_ATTRS, selected: true }, style)
236
255
  : clipped,
237
256
  );
238
- const childPrefix = isRoot ? "" : prefix + (last ? BLANK : PIPE);
239
- walk(node.children, childPrefix, false, selectedId, width, style, lines);
257
+ const nextPrefix = root ? "" : indent + (last ? BLANK : PIPE);
258
+ // Bound indentation to the display, independently of history depth.
259
+ const budget = Math.max(3, Math.min(30, width - 20));
260
+ const childPrefix = nextPrefix.length > budget ? "… " + nextPrefix.slice(-(budget - 2)) : nextPrefix;
261
+ for (let i = node.children.length - 1; i >= 0; i--) stack.push({ node: node.children[i]!, prefix: childPrefix, root: false, last: i === node.children.length - 1 });
240
262
  }
241
263
  }
242
264
 
@@ -259,129 +281,168 @@ export function treeFromPoints(
259
281
  byNode.set(p.node, list);
260
282
  }
261
283
 
262
- const childrenOf = new Map<number, number[]>();
263
- const nodeIds = new Set<number>([...parents.keys(), ...byNode.keys()]);
264
- for (const id of nodeIds) {
265
- const parent = parents.get(id);
266
- if (parent === undefined || parent === null) continue;
267
- const list = childrenOf.get(parent.node) ?? [];
268
- if (!list.includes(id)) list.push(id);
269
- childrenOf.set(parent.node, list);
270
- }
271
-
272
284
  const lastHere = [...points].reverse().find((p) => p.onPath && p.node === currentNode);
273
285
  const lastOnPath = lastHere ?? [...points].reverse().find((p) => p.onPath);
274
286
 
275
287
  function toNode(p: TreePoint, children: readonly TreeNode[]): TreeNode {
276
288
  return {
289
+ ...p,
277
290
  id: `${p.node}:${p.atEvent}`,
278
291
  prompt: p.prompt,
279
292
  onPath: p.onPath,
280
293
  current: lastOnPath !== undefined && lastOnPath.node === p.node && lastOnPath.atEvent === p.atEvent,
281
- kind: "user",
294
+ kind: p.kind ?? "user",
282
295
  children,
283
296
  };
284
297
  }
285
298
 
286
- const attached = new Set<number>();
287
-
288
- function forksAt(nodeId: number, atEvent: string): TreeNode[] {
289
- const out: TreeNode[] = [];
290
- for (const childId of childrenOf.get(nodeId) ?? []) {
291
- const parent = parents.get(childId);
292
- if (parent === undefined || parent === null) continue;
293
- if (parent.at_event === atEvent) {
294
- attached.add(childId);
295
- out.push(...chain(childId));
296
- }
299
+ // Wire event chains and branch edges separately. Recursing once per event
300
+ // overflows the JS stack on otherwise valid long sessions.
301
+ type MutableNode = TreeNode & { children: TreeNode[] };
302
+ const chains = new Map<number, MutableNode[]>();
303
+ const events = new Map<string, MutableNode>();
304
+ for (const [id, pts] of byNode) {
305
+ const nodes = pts.map(p => toNode(p, []) as MutableNode);
306
+ for (let i = 0; i < nodes.length; i++) {
307
+ events.set(nodes[i]!.id, nodes[i]!);
308
+ if (nodes[i + 1]) nodes[i]!.children.push(nodes[i + 1]!);
297
309
  }
298
- return out;
310
+ chains.set(id, nodes);
299
311
  }
300
-
301
- function danglingForks(nodeId: number): TreeNode[] {
302
- const out: TreeNode[] = [];
303
- for (const childId of childrenOf.get(nodeId) ?? []) {
304
- if (attached.has(childId)) continue;
305
- const parent = parents.get(childId);
306
- if (parent === undefined || parent === null) continue;
307
- attached.add(childId);
308
- out.push(...chain(childId));
309
- }
310
- return out;
311
- }
312
-
313
- function chain(nodeId: number): TreeNode[] {
314
- const pts = byNode.get(nodeId) ?? [];
315
- if (pts.length === 0) return danglingForks(nodeId);
316
-
317
- function fromIndex(i: number): TreeNode[] {
318
- const p = pts[i];
319
- if (p === undefined) return danglingForks(nodeId);
320
- const forks = forksAt(nodeId, p.atEvent);
321
- // Continuation (next point in this node) first, then forks at this event.
322
- const children = i + 1 < pts.length
323
- ? [...fromIndex(i + 1), ...forks]
324
- : [...forks, ...danglingForks(nodeId)];
325
- return [toNode(p, children)];
312
+ const forest: TreeNode[] = [];
313
+ for (const [id, nodes] of [...chains].sort(([a], [b]) => a - b)) {
314
+ const first = nodes[0];
315
+ if (!first) continue;
316
+ let parent = parents.get(id);
317
+ const visited = new Set<number>([id]);
318
+ while (parent && !chains.get(parent.node)?.length && !visited.has(parent.node)) {
319
+ visited.add(parent.node); parent = parents.get(parent.node);
326
320
  }
327
-
328
- return fromIndex(0);
321
+ const anchor = parent && !visited.has(parent.node) ? events.get(`${parent.node}:${parent.at_event}`) ?? chains.get(parent.node)?.at(-1) : undefined;
322
+ if (anchor) anchor.children.push(first); else forest.push(first);
329
323
  }
330
-
331
- const roots: number[] = [];
332
- for (const id of nodeIds) {
333
- const parent = parents.get(id);
334
- if (parent === undefined || parent === null) roots.push(id);
335
- }
336
- roots.sort((a, b) => a - b);
337
-
338
- const forest: TreeNode[] = [];
339
- for (const r of roots) forest.push(...chain(r));
340
324
  return forest;
341
325
  }
342
326
 
343
327
  export class TreeList implements Component {
344
328
  private current: TreeState;
345
329
  private style: Style | undefined;
330
+ private readonly bindings: Readonly<Record<string, readonly string[]>> | undefined;
346
331
  private readonly search = new Input({ prompt: "Search tree: " });
332
+ private readonly folded = new Set<string>();
333
+ private labelEditor: { node: TreeNode; input: Input } | undefined;
334
+ labelChange: { target: string; text: string } | undefined;
335
+ copyText: string | undefined;
336
+ showTimestamps = false;
347
337
  onInput: ((data: string) => void) | undefined;
348
338
 
349
339
  constructor(nodes: readonly TreeNode[], opts: CreateTreeOptions = {}) {
350
340
  this.current = createTree(nodes, opts);
351
341
  this.style = opts.style;
342
+ this.bindings = opts.bindings;
352
343
  }
353
344
 
354
345
  get state(): TreeState {
355
346
  return { ...this.current, nodes: this.filtered() };
356
347
  }
348
+ get selectedNode(): TreeNode | undefined { return flatten(this.filtered())[this.current.selected]; }
349
+ setNodes(nodes: readonly TreeNode[]): void {
350
+ const id = this.selectedNode?.id;
351
+ this.current = { ...this.current, nodes, selected: 0 };
352
+ this.current = { ...this.current, selected: Math.max(0, flatten(this.filtered()).findIndex(node => node.id === id)) };
353
+ }
357
354
 
358
355
  applyKey(key: Key): TreeState {
359
356
  return this.acceptInput(encodeKey(key));
360
357
  }
361
358
 
362
- get focused(): boolean { return this.search.focused; }
363
- set focused(value: boolean) { this.search.focused = value; }
359
+ get focused(): boolean { return (this.labelEditor?.input ?? this.search).focused; }
360
+ set focused(value: boolean) { (this.labelEditor?.input ?? this.search).focused = value; }
364
361
  setStyle(style: Style): void { this.style = style; }
365
362
  invalidate(): void { this.search.invalidate(); }
366
363
  handleInput(data: string): void { if (this.onInput) this.onInput(data); else this.acceptInput(data); }
367
364
  private filtered(): readonly TreeNode[] {
368
- const query = this.search.getValue().toLowerCase();
365
+ const tokens = this.search.getValue().toLowerCase().trim().split(/\s+/).filter(Boolean);
369
366
  const nodes = visibleNodes(this.current.nodes, this.current.filter);
370
- if (!query) return nodes;
371
- return flatten(nodes).filter((node) => node.prompt.toLowerCase().includes(query)).map((node) => ({ ...node, children: [] }));
367
+ if (tokens.length) return flatten(nodes).filter(node => tokens.every(token => `${node.content ?? node.prompt} ${node.label ?? ""}`.toLowerCase().includes(token))).map(node => ({ ...node, children: [] }));
368
+ const out: TreeNode[] = [];
369
+ const stack = [...nodes].reverse().map(node => ({ node, into: out }));
370
+ while (stack.length) {
371
+ const { node, into } = stack.pop()!;
372
+ const children: TreeNode[] = [];
373
+ into.push({ ...node, prompt: `${this.showTimestamps && node.labelAt ? `${node.labelAt} ` : ""}${this.folded.has(node.id) ? "▸ " : ""}${node.prompt}`, children });
374
+ if (!this.folded.has(node.id)) for (let i = node.children.length - 1; i >= 0; i--) stack.push({ node: node.children[i]!, into: children });
375
+ }
376
+ return out;
372
377
  }
373
- acceptInput(data: string): TreeState {
378
+ acceptInput(data: string, action?: string): TreeState {
374
379
  this.current = { ...this.current, confirmed: null, cancelled: false };
375
- const key = inputKey(data);
376
- if (keyMatches(data, "ctrl+c")) this.current = { ...this.current, cancelled: true };
377
- else if (key && ["up", "down", "enter", "escape", "tab"].includes(key.type)) {
378
- const next = applyTreeKey({ ...this.current, nodes: this.filtered() }, key);
379
- this.current = { ...next, nodes: this.current.nodes };
380
- } else if (keyMatches(data, "pageUp") || keyMatches(data, "pageDown")) {
380
+ this.labelChange = undefined; this.copyText = undefined;
381
+ const kb = getKeybindings();
382
+ if (this.labelEditor) {
383
+ if (kb.matches(data, "tui.select.cancel")) this.labelEditor = undefined;
384
+ else if (kb.matches(data, "tui.select.confirm")) {
385
+ this.labelChange = { target: this.labelEditor.node.target ?? this.labelEditor.node.id, text: this.labelEditor.input.getValue() };
386
+ this.labelEditor = undefined;
387
+ } else this.labelEditor.input.handleInput(data);
388
+ return this.state;
389
+ }
390
+ const filters: TreeFilter[] = ["default", "no-tools", "user-only", "labeled-only", "all"];
391
+ const filterByAction: Record<string, TreeFilter> = { "app.tree.filter.default": "default", "app.tree.filter.noTools": "no-tools",
392
+ "app.tree.filter.userOnly": "user-only", "app.tree.filter.labeledOnly": "labeled-only", "app.tree.filter.all": "all" };
393
+ const filter = action ? filterByAction[action] : undefined;
394
+ if (filter) { this.folded.clear(); this.current = { ...this.current, filter: this.current.filter === filter ? "default" : filter, selected: 0 }; return this.state; }
395
+ if (action === "app.tree.filter.cycleForward" || action === "app.tree.filter.cycleBackward") {
396
+ const index = Math.max(0, filters.indexOf(this.current.filter));
397
+ this.folded.clear();
398
+ this.current = { ...this.current, filter: filters[(index + (action.endsWith("Forward") ? 1 : 4)) % 5]!, selected: 0 }; return this.state;
399
+ }
400
+ if (action === "app.tree.toggleLabelTimestamp") { this.showTimestamps = !this.showTimestamps; return this.state; }
401
+ const selected = this.selectedNode;
402
+ if (action === "app.tree.editLabel") {
403
+ if (selected) { const input = new Input({ prompt: "Label: " }); input.setValue(selected.label ?? ""); input.focused = this.focused; this.labelEditor = { node: selected, input }; }
404
+ return this.state;
405
+ }
406
+ if (action === "app.message.copy") { this.copyText = selected?.content ?? selected?.prompt; return this.state; }
407
+ if (selected && (action === "app.tree.foldOrUp" || action === "app.tree.unfoldOrDown")) {
408
+ const flat = flatten(this.filtered());
409
+ const parentOf = new Map<string, TreeNode>();
410
+ for (const node of flat) for (const child of node.children) parentOf.set(child.id, node);
411
+ const parent = parentOf.get(selected.id);
412
+ if (action === "app.tree.foldOrUp") {
413
+ if (selected.children.length && (!parent || parent.children.length > 1) && !this.folded.has(selected.id)) this.folded.add(selected.id);
414
+ else {
415
+ let target = selected;
416
+ while (parentOf.has(target.id)) {
417
+ const ancestor = parentOf.get(target.id)!;
418
+ if (ancestor.children.length > 1 && target.id !== selected.id) break;
419
+ target = ancestor;
420
+ }
421
+ this.current = { ...this.current, selected: flat.findIndex(node => node.id === target.id) };
422
+ }
423
+ } else if (this.folded.delete(selected.id)) { /* Expand in place before moving to a child. */ }
424
+ else {
425
+ let target = selected;
426
+ while (target.children.length) { const branch = target.children.length > 1; target = target.children[0]!; if (branch) break; }
427
+ this.current = { ...this.current, selected: flat.findIndex(node => node.id === target.id) };
428
+ }
429
+ return this.state;
430
+ }
431
+ if (kb.matches(data, "tui.select.cancel")) {
432
+ if (this.search.getValue()) { this.search.setValue(""); this.folded.clear(); this.current = { ...this.current, selected: 0 }; }
433
+ else this.current = { ...this.current, cancelled: true };
434
+ }
435
+ else if (kb.matches(data, "tui.select.confirm")) this.current = { ...this.current, confirmed: selected ?? null };
436
+ else if (kb.matches(data, "tui.select.up") || kb.matches(data, "tui.select.down")) {
437
+ const size = flatten(this.filtered()).length;
438
+ this.current = { ...this.current, selected: size ? (this.current.selected + (kb.matches(data, "tui.select.up") ? -1 : 1) + size) % size : 0 };
439
+ } else if (kb.matches(data, "tui.input.tab")) this.current = toggleFilter(this.current);
440
+ else if (kb.matches(data, "tui.select.pageUp") || kb.matches(data, "tui.select.pageDown") || kb.matches(data, "tui.editor.cursorLeft") || kb.matches(data, "tui.editor.cursorRight")) {
381
441
  this.current = { ...this.current, selected: Math.max(0, Math.min(flatten(this.filtered()).length - 1,
382
- this.current.selected + (keyMatches(data, "pageUp") ? -10 : 10))) };
442
+ this.current.selected + (kb.matches(data, "tui.select.pageUp") || kb.matches(data, "tui.editor.cursorLeft") ? -10 : 10))) };
383
443
  } else {
384
- this.search.handleInput(data);
444
+ const before = this.search.getValue(); this.search.handleInput(data);
445
+ if (before !== this.search.getValue()) this.folded.clear();
385
446
  this.current = { ...this.current, selected: 0 };
386
447
  }
387
448
  return this.state;
@@ -389,6 +450,8 @@ export class TreeList implements Component {
389
450
 
390
451
  render(width: number): string[] {
391
452
  if (width <= 0) return [""];
453
+ if (this.labelEditor) return ["Edit label · empty removes it", ...this.labelEditor.input.render(width),
454
+ `${getKeybindings().getKeys("tui.select.confirm").join("/")} save · ${getKeybindings().getKeys("tui.select.cancel").join("/")} cancel`].map(line => truncateToWidth(line, width, ""));
392
455
  const body = renderTree(
393
456
  this.filtered(),
394
457
  { index: this.current.selected, filter: this.current.filter },
@@ -398,8 +461,11 @@ export class TreeList implements Component {
398
461
  // This is a viewport, not a total result cap. Arrows/search can reach every row.
399
462
  const start = Math.max(0, this.current.selected - 5);
400
463
  const rows = body.slice(start, start + 10);
401
- return ["Session tree · Enter navigates context at the exchange boundary", ...this.search.render(width),
402
- ...rows, `Tab ${this.current.filter === "all" ? "all messages" : "user messages"} · ↑↓ navigate · Esc cancel`]
464
+ const hints = this.bindings ? [["app.tree.filter.cycleForward", "filters"], ["app.tree.foldOrUp", "branch"], ["app.tree.editLabel", "label"],
465
+ ["app.tree.toggleLabelTimestamp", "label time"], ["app.message.copy", "copy"], ["tui.select.cancel", "cancel"]]
466
+ .flatMap(([action, label]) => this.bindings![action!]?.length ? [`${this.bindings![action!]!.join("/")} ${label}`] : []).join(" · ") : TREE_HINT;
467
+ return ["Session tree · Select an event to navigate", ...this.search.render(width),
468
+ ...rows, `${this.current.filter} · ${hints}`]
403
469
  .map((line) => truncateToWidth(line, width, ""));
404
470
  }
405
471
  }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Mario Zechner
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,17 @@
1
+ {
2
+ "repository": "https://github.com/earendil-works/pi",
3
+ "commit": "02eed88fd8912e54a804ddebd409e2e4c08ac5ef",
4
+ "path": "packages/coding-agent/src/utils",
5
+ "files": {
6
+ "syntax-highlight.ts": "626f94ab2eae32d77dd473bc24fa2d170fbf07d1",
7
+ "html.ts": "a13ad46f4888ad62587de59b0670b641a1e2f0a8",
8
+ "mime.ts": "9016b790f09f1073a707a9a94574c4fd3ae1c0f2",
9
+ "photon.ts": "6c320705eb975969abe145277566e3297a483272",
10
+ "exif-orientation.ts": "d1c47df1b7f1a63837ebbb9adb9cedee308a35e8",
11
+ "image-resize-core.ts": "e60820c7baedaf89d8b387b208c811c468d74ae4",
12
+ "image-resize.ts": "516a1e57a6be3d6840cb5b0fced3be6b3bc30286",
13
+ "image-resize-worker.ts": "ee881b3d9794ae60c28d8f6ee4d575d810638c17",
14
+ "image-convert.ts": "f781d53d1b195b1a8ced2822263f840a06350007",
15
+ "clipboard-command.ts": "700fc8e609478f76dc614331b5281d0f219f3641"
16
+ }
17
+ }
@@ -0,0 +1,44 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ /** Undefined means the command failed; an empty buffer is a successful result. */
4
+ export function runClipboardCommand(
5
+ command: string,
6
+ args: readonly string[],
7
+ options?: { input?: string; timeoutMs?: number; maxBufferBytes?: number },
8
+ ): Promise<Buffer | undefined> {
9
+ return new Promise((resolve) => {
10
+ // Clipboard writers can daemonize. Do not give them output pipes to retain.
11
+ const child = spawn(command, args, {
12
+ stdio: ["pipe", options?.input === undefined ? "pipe" : "ignore", "ignore"],
13
+ windowsHide: true,
14
+ });
15
+ const chunks: Buffer[] = [];
16
+ let length = 0;
17
+ let settled = false;
18
+ const finish = (result: Buffer | undefined): void => {
19
+ if (settled) return;
20
+ settled = true;
21
+ clearTimeout(timer);
22
+ resolve(result);
23
+ };
24
+ const abort = (): void => {
25
+ child.kill("SIGKILL");
26
+ child.stdout?.destroy();
27
+ child.stdin?.destroy();
28
+ finish(undefined);
29
+ };
30
+ const timer = setTimeout(abort, options?.timeoutMs ?? 3000);
31
+ child.on("error", () => finish(undefined));
32
+ child.on("close", (code) => {
33
+ if (!settled) finish(code === 0 ? Buffer.concat(chunks, length) : undefined);
34
+ });
35
+ child.stdout?.on("data", (chunk: Buffer) => {
36
+ if (settled) return;
37
+ length += chunk.length;
38
+ if (length > (options?.maxBufferBytes ?? 50 * 1024 * 1024)) abort();
39
+ else chunks.push(chunk);
40
+ });
41
+ child.stdin?.on("error", () => {}); // A writer may exit before consuming all input.
42
+ child.stdin?.end(options?.input);
43
+ });
44
+ }