@cueloop/client 0.1.0-alpha.7 → 0.1.0-alpha.70
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.
- package/native/build-pty.sh +51 -0
- package/native/build.sh +82 -0
- package/native/darwin-arm64/libcuelooppty.dylib +0 -0
- package/native/darwin-arm64/libcueloopvt.dylib +0 -0
- package/native/src/pty.zig +193 -0
- package/native/src/shim.zig +114 -0
- package/package.json +39 -32
- package/src/App.appearance.test.tsx +53 -0
- package/src/App.autoclose.test.tsx +76 -50
- package/src/App.diff.test.tsx +156 -42
- package/src/App.export.test.tsx +58 -40
- package/src/App.plan-review.test.tsx +363 -0
- package/src/App.prototype-markdown.test.tsx +92 -0
- package/src/App.prototype.test.tsx +176 -0
- package/src/App.readonly.test.tsx +128 -67
- package/src/App.rendering.test.tsx +61 -35
- package/src/App.submit-confirm.test.tsx +191 -0
- package/src/App.test.tsx +310 -114
- package/src/App.toast.test.tsx +89 -0
- package/src/App.tsx +1039 -799
- package/src/App.walk.test.tsx +298 -0
- package/src/App.workbench.test.tsx +461 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/annotation-palette.ts +61 -0
- package/src/app-key-state.ts +61 -0
- package/src/app-screens.tsx +432 -0
- package/src/app-view-model.ts +159 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +42 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +33 -0
- package/src/components/AnnotatableFileView.stories.tsx +42 -0
- package/src/components/AnnotatableFileView.test.tsx +102 -0
- package/src/components/AnnotatableFileView.tsx +126 -0
- package/src/components/AnnotationCards.stories.tsx +87 -0
- package/src/components/AnnotationCards.tsx +303 -0
- package/src/components/AppHeader.stories.tsx +50 -0
- package/src/components/AppHeader.tsx +83 -0
- package/src/components/AppShell.stories.tsx +89 -0
- package/src/components/AppShell.tsx +286 -0
- package/src/components/AppShell.zoom.test.tsx +94 -0
- package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
- package/src/components/BareWorkbenchFileView.tsx +64 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +45 -0
- package/src/components/ChangesColumn.stories.tsx +39 -0
- package/src/components/ChangesColumn.tsx +171 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +84 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +60 -0
- package/src/components/ConfirmCard.stories.tsx +35 -0
- package/src/components/ConfirmCard.tsx +128 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +66 -0
- package/src/components/DiffContentView.scroll.test.tsx +121 -0
- package/src/components/DiffContentView.split.test.tsx +140 -0
- package/src/components/DiffContentView.stories.tsx +80 -0
- package/src/components/DiffContentView.syntax.test.tsx +82 -0
- package/src/components/DiffContentView.tsx +859 -0
- package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/components/DiscussionMarkerRail.tsx +181 -0
- package/src/components/EditorGrid.stories.tsx +59 -0
- package/src/components/EditorGrid.tabs.test.tsx +118 -0
- package/src/components/EditorGrid.tsx +368 -0
- package/src/components/EditorGrid.zoom.test.tsx +68 -0
- package/src/components/FileContentsView.stories.tsx +16 -0
- package/src/components/FileContentsView.test.tsx +29 -0
- package/src/components/FileContentsView.tsx +98 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +103 -0
- package/src/components/MenuBar.stories.tsx +28 -0
- package/src/components/MenuBar.tsx +44 -0
- package/src/components/OverlayScrollbar.stories.tsx +33 -0
- package/src/components/OverlayScrollbar.test.tsx +139 -0
- package/src/components/OverlayScrollbar.tsx +129 -0
- package/src/components/PaneGrid.stories.tsx +84 -0
- package/src/components/PaneGrid.tsx +128 -0
- package/src/components/PanelColumn.stories.tsx +25 -0
- package/src/components/PanelColumn.tsx +95 -0
- package/src/components/ProjectTreeView.stories.tsx +25 -0
- package/src/components/ProjectTreeView.test.tsx +32 -0
- package/src/components/ProjectTreeView.tsx +83 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +87 -0
- package/src/components/PrototypeContentView.stories.tsx +19 -0
- package/src/components/PrototypeContentView.tsx +446 -0
- package/src/components/RootOverlay.stories.tsx +44 -0
- package/src/components/RootOverlay.tsx +35 -0
- package/src/components/ScrollArea.stories.tsx +20 -0
- package/src/components/ScrollArea.tsx +44 -0
- package/src/components/SettingsDialog.stories.tsx +117 -0
- package/src/components/SettingsDialog.tsx +195 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +112 -0
- package/src/components/ShellHeader.stories.tsx +50 -0
- package/src/components/ShellHeader.tsx +137 -0
- package/src/components/ThreadFooter.stories.tsx +30 -0
- package/src/components/ThreadFooter.test.tsx +31 -0
- package/src/components/ThreadFooter.tsx +54 -0
- package/src/components/ThreadTree.stories.tsx +46 -0
- package/src/components/ThreadTree.tsx +219 -0
- package/src/components/ThreadView.selection.test.tsx +812 -0
- package/src/components/ThreadView.slash.test.tsx +158 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +330 -0
- package/src/components/ThreadsSidebar.stories.tsx +36 -0
- package/src/components/ThreadsSidebar.tsx +71 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +45 -0
- package/src/components/Tooltip.stories.tsx +26 -0
- package/src/components/Tooltip.tsx +72 -0
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +157 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +152 -0
- package/src/components/WelcomePlayground.stories.tsx +12 -0
- package/src/components/WelcomePlayground.test.tsx +47 -0
- package/src/components/WelcomePlayground.tsx +140 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1845 -0
- package/src/components/agent-launcher.stories.tsx +26 -0
- package/src/components/agent-launcher.test.tsx +50 -0
- package/src/components/agent-launcher.tsx +149 -0
- package/src/components/diff-content-view-layout.test.ts +198 -0
- package/src/components/diff-content-view-layout.ts +84 -0
- package/src/components/editor-grid.test.ts +86 -0
- package/src/components/editor-grid.ts +138 -0
- package/src/components/fade-title.test.ts +49 -0
- package/src/components/fade-title.ts +61 -0
- package/src/components/file-tree.test.ts +60 -0
- package/src/components/file-tree.ts +112 -0
- package/src/components/pages/Workspace.stories.tsx +181 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +52 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +60 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +66 -0
- package/src/components/primitives/IconButton.stories.tsx +36 -0
- package/src/components/primitives/IconButton.tsx +62 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +23 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +102 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/Tree.stories.tsx +124 -0
- package/src/components/primitives/Tree.tsx +96 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/primitives/icons.ts +61 -0
- package/src/components/primitives/tree-model.test.ts +99 -0
- package/src/components/primitives/tree-model.ts +137 -0
- package/src/components/quick-actions-editor.stories.tsx +54 -0
- package/src/components/quick-actions-editor.test.tsx +49 -0
- package/src/components/quick-actions-editor.tsx +150 -0
- package/src/components/session-tree.test.ts +134 -0
- package/src/components/session-tree.ts +108 -0
- package/src/components/stories-app.tsx +184 -0
- package/src/components/stories.test.tsx +66 -0
- package/src/components/story-fixtures.ts +102 -0
- package/src/components/story-tree.test.ts +100 -0
- package/src/components/story.ts +142 -0
- package/src/components/syntax-highlight.ts +110 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +199 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +37 -0
- package/src/components/truncate-text.ts +7 -0
- package/src/config.test.ts +461 -31
- package/src/config.ts +389 -57
- package/src/diff-fold.test.ts +78 -0
- package/src/diff-fold.ts +126 -0
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +153 -0
- package/src/diff-intraline.test.ts +165 -0
- package/src/diff-intraline.ts +146 -0
- package/src/diff-syntax.test.ts +112 -0
- package/src/diff-syntax.ts +179 -0
- package/src/discussions.ts +79 -0
- package/src/editor.test.ts +149 -0
- package/src/editor.ts +139 -9
- package/src/ghostty-terminal.test.ts +85 -0
- package/src/ghostty-terminal.ts +199 -0
- package/src/herdr.test.ts +9 -34
- package/src/herdr.ts +25 -21
- package/src/index.ts +11 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +32 -0
- package/src/intent-dispatch.test.ts +698 -0
- package/src/intent-dispatch.ts +700 -0
- package/src/key-bindings.test.ts +156 -0
- package/src/key-bindings.ts +465 -0
- package/src/keymap.test.ts +611 -0
- package/src/keymap.ts +410 -0
- package/src/kitty-image.test.ts +66 -0
- package/src/kitty-image.ts +138 -0
- package/src/mark-runs.ts +120 -0
- package/src/prototype-browser.test.ts +56 -0
- package/src/prototype-browser.ts +307 -0
- package/src/prototype-pixels.tsx +35 -0
- package/src/pty.test.ts +81 -0
- package/src/pty.ts +245 -0
- package/src/ready-signal.test.ts +85 -0
- package/src/ready-signal.ts +52 -0
- package/src/run.ts +30 -9
- package/src/serve.test.ts +24 -12
- package/src/serve.ts +20 -9
- package/src/share.ts +215 -0
- package/src/skills.test.ts +74 -0
- package/src/skills.ts +68 -0
- package/src/slash-palette.test.ts +54 -0
- package/src/slash-palette.ts +104 -0
- package/src/split-diff.test.ts +110 -0
- package/src/split-diff.ts +149 -0
- package/src/test-support.test.ts +264 -0
- package/src/test-support.ts +355 -0
- package/src/theme-presets.test.ts +51 -0
- package/src/theme-presets.ts +214 -0
- package/src/theme.ts +84 -19
- package/src/thread-chords.test.ts +170 -0
- package/src/thread-chords.ts +169 -0
- package/src/thread-controller.curate.test.ts +438 -0
- package/src/thread-controller.live-diff.test.ts +199 -0
- package/src/thread-controller.share.test.ts +472 -0
- package/src/thread-controller.tree.test.ts +255 -0
- package/src/thread-controller.ts +1509 -0
- package/src/thread-selection.test.ts +210 -0
- package/src/thread-selection.ts +243 -0
- package/src/tree-view.test.ts +128 -0
- package/src/tree-view.ts +148 -0
- package/src/use-annotation-surface.tsx +851 -0
- package/src/use-changes-workbench.ts +151 -0
- package/src/use-frame-measure.ts +38 -0
- package/src/use-settings-dialog.tsx +274 -0
- package/src/use-terminal-virtualizer.ts +233 -0
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +66 -9
- package/src/view-diff.ts +231 -23
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +406 -0
- package/src/view-plan.ts +570 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +74 -0
- package/src/syntax.test.ts +0 -41
- package/src/syntax.ts +0 -88
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -312
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// OpenTUI renderer for the headless tree-model; the caller owns expansion and selection.
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { DARK, type Theme } from "../../theme";
|
|
5
|
+
import { flattenTree, statusMeta, type TreeNode, type TreeTone } from "./tree-model";
|
|
6
|
+
import { NERD_TREE_ICONS, type TreeIcons } from "./icons";
|
|
7
|
+
|
|
8
|
+
export interface TreeProps {
|
|
9
|
+
nodes: readonly TreeNode[];
|
|
10
|
+
expandedIds: ReadonlySet<string>;
|
|
11
|
+
selectedId?: string;
|
|
12
|
+
flattenEmptyDirectories?: boolean;
|
|
13
|
+
showStatus?: boolean;
|
|
14
|
+
/** Drop the folder/file glyph prefix, for a plain text tree (e.g. the settings nav). */
|
|
15
|
+
hideIcons?: boolean;
|
|
16
|
+
indentWidth?: number;
|
|
17
|
+
icons?: TreeIcons;
|
|
18
|
+
onSelect?: (id: string) => void;
|
|
19
|
+
onToggle?: (id: string) => void;
|
|
20
|
+
theme?: Theme;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function rowGlyph(isFolder: boolean, expanded: boolean, icons: TreeIcons): string {
|
|
24
|
+
if (!isFolder) return icons.leaf;
|
|
25
|
+
|
|
26
|
+
return expanded ? icons.expanded : icons.collapsed;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function toneColor(tone: TreeTone, theme: Theme): string {
|
|
30
|
+
if (tone === "green") return theme.green;
|
|
31
|
+
if (tone === "blue") return theme.blue;
|
|
32
|
+
if (tone === "red") return theme.red;
|
|
33
|
+
|
|
34
|
+
return theme.textDim;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function Tree({
|
|
38
|
+
nodes,
|
|
39
|
+
expandedIds,
|
|
40
|
+
selectedId,
|
|
41
|
+
flattenEmptyDirectories,
|
|
42
|
+
showStatus,
|
|
43
|
+
hideIcons,
|
|
44
|
+
indentWidth = 2,
|
|
45
|
+
icons = NERD_TREE_ICONS,
|
|
46
|
+
onSelect,
|
|
47
|
+
onToggle,
|
|
48
|
+
theme,
|
|
49
|
+
}: TreeProps): React.ReactNode {
|
|
50
|
+
const tokens = theme ?? DARK;
|
|
51
|
+
const rows = flattenTree(nodes, { expandedIds, flattenEmptyDirectories });
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<box style={{ flexDirection: "column" }}>
|
|
55
|
+
{rows.map((row) => {
|
|
56
|
+
const selected = row.id === selectedId;
|
|
57
|
+
const status = showStatus && row.status !== undefined ? statusMeta(row.status) : null;
|
|
58
|
+
const labelColor = selected
|
|
59
|
+
? tokens.text
|
|
60
|
+
: status
|
|
61
|
+
? toneColor(status.tone, tokens)
|
|
62
|
+
: row.isFolder
|
|
63
|
+
? tokens.text
|
|
64
|
+
: tokens.textMuted;
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<box
|
|
68
|
+
key={row.id}
|
|
69
|
+
id={`tree-row-${row.id}`}
|
|
70
|
+
style={{
|
|
71
|
+
flexDirection: "row",
|
|
72
|
+
paddingLeft: 1 + row.depth * indentWidth,
|
|
73
|
+
paddingRight: 1,
|
|
74
|
+
backgroundColor: selected ? tokens.elevated : undefined,
|
|
75
|
+
}}
|
|
76
|
+
onMouseUp={() => (row.isFolder ? onToggle?.(row.id) : onSelect?.(row.id))}
|
|
77
|
+
>
|
|
78
|
+
{hideIcons ? null : (
|
|
79
|
+
<text fg={row.isFolder ? tokens.blue : tokens.textDim}>
|
|
80
|
+
{row.icon ?? rowGlyph(row.isFolder, row.expanded, icons)}{" "}
|
|
81
|
+
</text>
|
|
82
|
+
)}
|
|
83
|
+
<box style={{ flexShrink: 1, minWidth: 0 }}>
|
|
84
|
+
<text fg={selected ? tokens.accent : labelColor} truncate>
|
|
85
|
+
{row.label}
|
|
86
|
+
</text>
|
|
87
|
+
</box>
|
|
88
|
+
<box style={{ flexGrow: 1 }} />
|
|
89
|
+
{row.badge !== undefined ? <text fg={tokens.textDim}>{row.badge}</text> : null}
|
|
90
|
+
{status ? <text fg={toneColor(status.tone, tokens)}> {status.letter}</text> : null}
|
|
91
|
+
</box>
|
|
92
|
+
);
|
|
93
|
+
})}
|
|
94
|
+
</box>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locks the single frame border rule: every bordered frame reads this token,
|
|
3
|
+
* so a regression to square corners (or any other style) fails here first,
|
|
4
|
+
* before the slower VRT snapshots catch it. Decision: rounded (issue #116).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { expect, test } from "bun:test";
|
|
8
|
+
import { FRAME_BORDER_STYLE } from "./frame";
|
|
9
|
+
|
|
10
|
+
test("frames use the rounded border style", () => {
|
|
11
|
+
expect(FRAME_BORDER_STYLE).toBe("rounded");
|
|
12
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one frame border rule for the design system. Every bordered frame -
|
|
3
|
+
* cards, dialogs, and the stories gallery chrome - reads its corner style
|
|
4
|
+
* from FRAME_BORDER_STYLE, so the rounded-corner decision lives in a single
|
|
5
|
+
* place and can never drift between frames. Buttons stay text-first and
|
|
6
|
+
* borderless: the frame they sit in carries the border, not the button.
|
|
7
|
+
*
|
|
8
|
+
* Decision: rounded on the frames, not on the buttons (issue #116).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { BorderStyle } from "@opentui/core";
|
|
12
|
+
|
|
13
|
+
/** Corner style shared by every bordered frame in the app. */
|
|
14
|
+
export const FRAME_BORDER_STYLE: BorderStyle = "rounded";
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Nerd Font glyphs (Private Use Area / codicons) plus the plus-minus diff mark; render as icons under a Nerd Font, tofu without one.
|
|
2
|
+
|
|
3
|
+
export const NERD = {
|
|
4
|
+
folderClosed: "",
|
|
5
|
+
folderOpen: "",
|
|
6
|
+
file: "",
|
|
7
|
+
chevronRight: "",
|
|
8
|
+
chevronDown: "",
|
|
9
|
+
settings: "",
|
|
10
|
+
search: "",
|
|
11
|
+
expand: "",
|
|
12
|
+
diff: "",
|
|
13
|
+
copy: "",
|
|
14
|
+
unfold: "",
|
|
15
|
+
kebab: "⋮",
|
|
16
|
+
pin: "⚑",
|
|
17
|
+
close: "✕",
|
|
18
|
+
listTree: "",
|
|
19
|
+
sidebarLeft: "",
|
|
20
|
+
sidebarLeftOff: "",
|
|
21
|
+
sidebarRight: "",
|
|
22
|
+
sidebarRightOff: "",
|
|
23
|
+
submit: "",
|
|
24
|
+
zoom: "⛶",
|
|
25
|
+
split: "",
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
// The thin rule under each pane header: an overline sits at the top edge of its cell so it hugs the
|
|
29
|
+
// header text (unlike the default light-horizontal, which floats mid-cell); painted in divider gray upstream.
|
|
30
|
+
export const HEADER_UNDERLINE_CHARS = {
|
|
31
|
+
topLeft: "\u250c",
|
|
32
|
+
topRight: "\u2510",
|
|
33
|
+
bottomLeft: "\u2514",
|
|
34
|
+
bottomRight: "\u2518",
|
|
35
|
+
horizontal: "\u203e",
|
|
36
|
+
vertical: "\u2502",
|
|
37
|
+
topT: "\u252c",
|
|
38
|
+
bottomT: "\u2534",
|
|
39
|
+
leftT: "\u251c",
|
|
40
|
+
rightT: "\u2524",
|
|
41
|
+
cross: "\u253c",
|
|
42
|
+
} as const;
|
|
43
|
+
|
|
44
|
+
export interface TreeIcons {
|
|
45
|
+
collapsed: string;
|
|
46
|
+
expanded: string;
|
|
47
|
+
leaf: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const NERD_TREE_ICONS: TreeIcons = {
|
|
51
|
+
collapsed: NERD.folderClosed,
|
|
52
|
+
expanded: NERD.folderOpen,
|
|
53
|
+
leaf: NERD.file,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Fallback for terminals without a Nerd Font.
|
|
57
|
+
export const ASCII_TREE_ICONS: TreeIcons = {
|
|
58
|
+
collapsed: "▸",
|
|
59
|
+
expanded: "▾",
|
|
60
|
+
leaf: " ",
|
|
61
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { allFolderIds, flattenTree, statusMeta, type TreeNode } from "./tree-model";
|
|
3
|
+
|
|
4
|
+
const TREE: TreeNode[] = [
|
|
5
|
+
{
|
|
6
|
+
id: "src",
|
|
7
|
+
label: "src",
|
|
8
|
+
children: [
|
|
9
|
+
{ id: "src/a.ts", label: "a.ts", status: "modified" },
|
|
10
|
+
{
|
|
11
|
+
id: "src/nested",
|
|
12
|
+
label: "nested",
|
|
13
|
+
children: [
|
|
14
|
+
{
|
|
15
|
+
id: "src/nested/only",
|
|
16
|
+
label: "only",
|
|
17
|
+
children: [{ id: "deep", label: "deep.ts", status: "added" }],
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
{ id: "readme", label: "README.md" },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
describe("flattenTree", () => {
|
|
27
|
+
test("a collapsed folder shows only itself; expanding it reveals its children", () => {
|
|
28
|
+
// Act
|
|
29
|
+
const collapsed = flattenTree(TREE, { expandedIds: new Set() });
|
|
30
|
+
const rows = flattenTree(TREE, { expandedIds: new Set(["src"]) });
|
|
31
|
+
|
|
32
|
+
// Assert
|
|
33
|
+
expect(collapsed.map((row) => row.label)).toEqual(["src", "README.md"]);
|
|
34
|
+
expect(rows.map((row) => [row.label, row.depth, row.isFolder])).toEqual([
|
|
35
|
+
["src", 0, true],
|
|
36
|
+
["a.ts", 1, false],
|
|
37
|
+
["nested", 1, true],
|
|
38
|
+
["README.md", 0, false],
|
|
39
|
+
]);
|
|
40
|
+
expect(rows.find((row) => row.label === "src")!.expanded).toBe(true);
|
|
41
|
+
expect(rows.find((row) => row.label === "a.ts")!.status).toBe("modified");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("flattenEmptyDirectories collapses a single-subfolder chain into one row", () => {
|
|
45
|
+
// Act - src open but the chain itself collapsed (none of its ids expanded)
|
|
46
|
+
const collapsed = flattenTree(TREE, {
|
|
47
|
+
expandedIds: new Set(["src"]),
|
|
48
|
+
flattenEmptyDirectories: true,
|
|
49
|
+
});
|
|
50
|
+
// Act - expanding via any id along the chain opens it
|
|
51
|
+
const expanded = flattenTree(TREE, {
|
|
52
|
+
expandedIds: new Set(["src", "src/nested/only"]),
|
|
53
|
+
flattenEmptyDirectories: true,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Assert
|
|
57
|
+
expect(collapsed.map((row) => row.label)).toEqual(["src", "a.ts", "nested/only", "README.md"]);
|
|
58
|
+
expect(expanded.map((row) => row.label)).toEqual([
|
|
59
|
+
"src",
|
|
60
|
+
"a.ts",
|
|
61
|
+
"nested/only",
|
|
62
|
+
"deep.ts",
|
|
63
|
+
"README.md",
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("without the flag the chain stays one folder per row", () => {
|
|
68
|
+
// Act
|
|
69
|
+
const rows = flattenTree(TREE, {
|
|
70
|
+
expandedIds: new Set(["src", "src/nested", "src/nested/only"]),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Assert
|
|
74
|
+
expect(rows.map((row) => [row.label, row.depth])).toEqual([
|
|
75
|
+
["src", 0],
|
|
76
|
+
["a.ts", 1],
|
|
77
|
+
["nested", 1],
|
|
78
|
+
["only", 2],
|
|
79
|
+
["deep.ts", 3],
|
|
80
|
+
["README.md", 0],
|
|
81
|
+
]);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("allFolderIds", () => {
|
|
86
|
+
test("collects every folder, not the leaves", () => {
|
|
87
|
+
// Assert
|
|
88
|
+
expect([...allFolderIds(TREE)].sort()).toEqual(["src", "src/nested", "src/nested/only"]);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("statusMeta", () => {
|
|
93
|
+
test("maps each status to a letter and a tone", () => {
|
|
94
|
+
// Assert
|
|
95
|
+
expect(statusMeta("added")).toEqual({ letter: "A", tone: "green" });
|
|
96
|
+
expect(statusMeta("deleted")).toEqual({ letter: "D", tone: "red" });
|
|
97
|
+
expect(statusMeta("ignored")).toEqual({ letter: "I", tone: "muted" });
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Headless tree model mirrored from @pierre/trees (DOM-only): share the model, render our own.
|
|
2
|
+
|
|
3
|
+
export type GitStatus = "added" | "modified" | "deleted" | "renamed" | "untracked" | "ignored";
|
|
4
|
+
|
|
5
|
+
export interface TreeNode {
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
children?: TreeNode[];
|
|
9
|
+
status?: GitStatus;
|
|
10
|
+
badge?: string;
|
|
11
|
+
icon?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface FlattenOptions {
|
|
15
|
+
expandedIds: ReadonlySet<string>;
|
|
16
|
+
flattenEmptyDirectories?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface VisibleRow {
|
|
20
|
+
id: string;
|
|
21
|
+
label: string;
|
|
22
|
+
depth: number;
|
|
23
|
+
isFolder: boolean;
|
|
24
|
+
expanded: boolean;
|
|
25
|
+
status?: GitStatus;
|
|
26
|
+
badge?: string;
|
|
27
|
+
icon?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isFolder(node: TreeNode): boolean {
|
|
31
|
+
return node.children !== undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface CollapsedChain {
|
|
35
|
+
label: string;
|
|
36
|
+
tail: TreeNode;
|
|
37
|
+
chainIds: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// flattenEmptyDirectories: fold a chain of single-subfolder folders into one row.
|
|
41
|
+
function collapseChain(node: TreeNode): CollapsedChain {
|
|
42
|
+
let tail = node;
|
|
43
|
+
const labels = [node.label];
|
|
44
|
+
const chainIds = [node.id];
|
|
45
|
+
|
|
46
|
+
while (tail.children?.length === 1 && isFolder(tail.children[0]!)) {
|
|
47
|
+
tail = tail.children[0]!;
|
|
48
|
+
labels.push(tail.label);
|
|
49
|
+
chainIds.push(tail.id);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { label: labels.join("/"), tail, chainIds };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function flattenTree(nodes: readonly TreeNode[], options: FlattenOptions): VisibleRow[] {
|
|
56
|
+
const rows: VisibleRow[] = [];
|
|
57
|
+
const walk = (list: readonly TreeNode[], depth: number): void => {
|
|
58
|
+
for (const node of list) {
|
|
59
|
+
if (!isFolder(node)) {
|
|
60
|
+
const leaf: VisibleRow = {
|
|
61
|
+
id: node.id,
|
|
62
|
+
label: node.label,
|
|
63
|
+
depth,
|
|
64
|
+
isFolder: false,
|
|
65
|
+
expanded: false,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
if (node.status !== undefined) leaf.status = node.status;
|
|
69
|
+
if (node.badge !== undefined) leaf.badge = node.badge;
|
|
70
|
+
if (node.icon !== undefined) leaf.icon = node.icon;
|
|
71
|
+
rows.push(leaf);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const collapsed = options.flattenEmptyDirectories
|
|
75
|
+
? collapseChain(node)
|
|
76
|
+
: { label: node.label, tail: node, chainIds: [node.id] };
|
|
77
|
+
// open when any id along a collapsed chain is expanded
|
|
78
|
+
const expanded = collapsed.chainIds.some((id) => options.expandedIds.has(id));
|
|
79
|
+
const row: VisibleRow = {
|
|
80
|
+
id: node.id,
|
|
81
|
+
label: collapsed.label,
|
|
82
|
+
depth,
|
|
83
|
+
isFolder: true,
|
|
84
|
+
expanded,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
if (node.badge !== undefined) row.badge = node.badge;
|
|
88
|
+
if (node.icon !== undefined) row.icon = node.icon;
|
|
89
|
+
rows.push(row);
|
|
90
|
+
if (expanded) walk(collapsed.tail.children ?? [], depth + 1);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
walk(nodes, 0);
|
|
95
|
+
|
|
96
|
+
return rows;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function allFolderIds(nodes: readonly TreeNode[]): Set<string> {
|
|
100
|
+
const ids = new Set<string>();
|
|
101
|
+
const walk = (list: readonly TreeNode[]): void => {
|
|
102
|
+
for (const node of list) {
|
|
103
|
+
if (isFolder(node)) {
|
|
104
|
+
ids.add(node.id);
|
|
105
|
+
walk(node.children ?? []);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
walk(nodes);
|
|
111
|
+
|
|
112
|
+
return ids;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type TreeTone = "green" | "blue" | "red" | "muted";
|
|
116
|
+
|
|
117
|
+
export interface StatusMeta {
|
|
118
|
+
letter: string;
|
|
119
|
+
tone: TreeTone;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function statusMeta(status: GitStatus): StatusMeta {
|
|
123
|
+
switch (status) {
|
|
124
|
+
case "added":
|
|
125
|
+
return { letter: "A", tone: "green" };
|
|
126
|
+
case "modified":
|
|
127
|
+
return { letter: "M", tone: "blue" };
|
|
128
|
+
case "deleted":
|
|
129
|
+
return { letter: "D", tone: "red" };
|
|
130
|
+
case "renamed":
|
|
131
|
+
return { letter: "R", tone: "blue" };
|
|
132
|
+
case "untracked":
|
|
133
|
+
return { letter: "U", tone: "green" };
|
|
134
|
+
case "ignored":
|
|
135
|
+
return { letter: "I", tone: "muted" };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import { DEFAULT_QUICK_ACTIONS } from "../config";
|
|
4
|
+
import type { Story, StoryMeta } from "./story";
|
|
5
|
+
import { QuickActionsEditor } from "./quick-actions-editor";
|
|
6
|
+
|
|
7
|
+
export const meta: StoryMeta = { title: "Chrome/QuickActionsEditor" };
|
|
8
|
+
|
|
9
|
+
/** The editor renders inside the settings body column. */
|
|
10
|
+
function BodyFrame({ children }: { children: React.ReactNode }): React.ReactNode {
|
|
11
|
+
return (
|
|
12
|
+
<box style={{ width: 54, height: 22, paddingLeft: 2, backgroundColor: DARK.elevated }}>
|
|
13
|
+
{children}
|
|
14
|
+
</box>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const noop = () => {};
|
|
19
|
+
|
|
20
|
+
export const Collapsed: Story = {
|
|
21
|
+
render: () => (
|
|
22
|
+
<BodyFrame>
|
|
23
|
+
<QuickActionsEditor
|
|
24
|
+
actions={DEFAULT_QUICK_ACTIONS}
|
|
25
|
+
selectedIndex={0}
|
|
26
|
+
expandedIndex={null}
|
|
27
|
+
onToggleExpand={noop}
|
|
28
|
+
onEditMetadata={noop}
|
|
29
|
+
onReset={noop}
|
|
30
|
+
onAdd={noop}
|
|
31
|
+
theme={DARK}
|
|
32
|
+
/>
|
|
33
|
+
</BodyFrame>
|
|
34
|
+
),
|
|
35
|
+
size: { width: 54, height: 22 },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const RowExpanded: Story = {
|
|
39
|
+
render: () => (
|
|
40
|
+
<BodyFrame>
|
|
41
|
+
<QuickActionsEditor
|
|
42
|
+
actions={DEFAULT_QUICK_ACTIONS}
|
|
43
|
+
selectedIndex={1}
|
|
44
|
+
expandedIndex={1}
|
|
45
|
+
onToggleExpand={noop}
|
|
46
|
+
onEditMetadata={noop}
|
|
47
|
+
onReset={noop}
|
|
48
|
+
onAdd={noop}
|
|
49
|
+
theme={DARK}
|
|
50
|
+
/>
|
|
51
|
+
</BodyFrame>
|
|
52
|
+
),
|
|
53
|
+
size: { width: 54, height: 22 },
|
|
54
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** The Actions editor scrolls to keep the selected row in view when the list outgrows the pane. */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import React from "react";
|
|
5
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
6
|
+
import type { QuickAction } from "../config";
|
|
7
|
+
import { settle } from "../test-support";
|
|
8
|
+
import { QuickActionsEditor } from "./quick-actions-editor";
|
|
9
|
+
|
|
10
|
+
const MANY: QuickAction[] = Array.from({ length: 14 }, (_, index) => ({
|
|
11
|
+
prompt: `Action number ${index}`,
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
async function mount(selectedIndex: number) {
|
|
15
|
+
const setup = await testRender(
|
|
16
|
+
<QuickActionsEditor
|
|
17
|
+
actions={MANY}
|
|
18
|
+
selectedIndex={selectedIndex}
|
|
19
|
+
expandedIndex={null}
|
|
20
|
+
onToggleExpand={() => {}}
|
|
21
|
+
onEditMetadata={() => {}}
|
|
22
|
+
onReset={() => {}}
|
|
23
|
+
onAdd={() => {}}
|
|
24
|
+
/>,
|
|
25
|
+
{ width: 60, height: 10 },
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
await settle(setup);
|
|
29
|
+
await settle(setup);
|
|
30
|
+
await settle(setup);
|
|
31
|
+
await settle(setup);
|
|
32
|
+
|
|
33
|
+
return setup;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("QuickActionsEditor scrolling", () => {
|
|
37
|
+
test("a list too long for the pane clips into the scroll area instead of overlapping rows", async () => {
|
|
38
|
+
const setup = await mount(0);
|
|
39
|
+
const lines = setup.captureCharFrame().split("\n");
|
|
40
|
+
|
|
41
|
+
// the top rows read cleanly on their own lines - the overflow no longer paints rows on top of each other
|
|
42
|
+
expect(lines.some((line) => line.includes("Action number 0"))).toBe(true);
|
|
43
|
+
expect(lines.some((line) => line.includes("Action number 1"))).toBe(true);
|
|
44
|
+
// a bottom row is scrolled out of the clipped pane, not overlaid onto a visible one
|
|
45
|
+
expect(setup.captureCharFrame()).not.toContain("Action number 13");
|
|
46
|
+
|
|
47
|
+
setup.renderer.destroy();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Settings "Actions" body: the quick-action vocabulary editor. Each row is a
|
|
3
|
+
* clickable prompt; the selected-and-expanded row reveals a focused input for the
|
|
4
|
+
* per-action system prompt (the extra guidance appended when the action is used).
|
|
5
|
+
* A reset-to-defaults control and an add-action row bracket the list. Controlled:
|
|
6
|
+
* the app owns the actions, the selection, and which row is expanded.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import React, { useEffect, useRef } from "react";
|
|
10
|
+
import type { KeyBinding, ScrollBoxRenderable, TextareaRenderable } from "@opentui/core";
|
|
11
|
+
import type { QuickAction } from "../config";
|
|
12
|
+
import type { Theme } from "../theme";
|
|
13
|
+
import { ScrollArea } from "./ScrollArea";
|
|
14
|
+
import { useComponentTheme } from "./theme-context";
|
|
15
|
+
|
|
16
|
+
// ⏎ never inserts a newline: a system prompt is one line, esc closes the editor.
|
|
17
|
+
const ACTION_INPUT_KEY_BINDINGS: KeyBinding[] = [{ name: "return", action: "submit" }];
|
|
18
|
+
|
|
19
|
+
export interface QuickActionsEditorProps {
|
|
20
|
+
actions: QuickAction[];
|
|
21
|
+
/** The highlighted row; equals the settings body row index. */
|
|
22
|
+
selectedIndex: number;
|
|
23
|
+
/** The row whose system-prompt input is open and focused, or null. */
|
|
24
|
+
expandedIndex: number | null;
|
|
25
|
+
/** Select and toggle a row's system-prompt input open/closed. */
|
|
26
|
+
onToggleExpand: (index: number) => void;
|
|
27
|
+
onEditMetadata: (index: number, metadata: string) => void;
|
|
28
|
+
onReset: () => void;
|
|
29
|
+
onAdd: () => void;
|
|
30
|
+
theme?: Theme;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function QuickActionsEditor({
|
|
34
|
+
actions,
|
|
35
|
+
selectedIndex,
|
|
36
|
+
expandedIndex,
|
|
37
|
+
onToggleExpand,
|
|
38
|
+
onEditMetadata,
|
|
39
|
+
onReset,
|
|
40
|
+
onAdd,
|
|
41
|
+
theme,
|
|
42
|
+
}: QuickActionsEditorProps): React.ReactNode {
|
|
43
|
+
const tokens = useComponentTheme(theme);
|
|
44
|
+
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
scrollRef.current?.scrollChildIntoView(`action-row-${selectedIndex}`);
|
|
48
|
+
}, [selectedIndex]);
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
52
|
+
<box style={{ flexDirection: "row" }}>
|
|
53
|
+
<box style={{ flexGrow: 1 }}>
|
|
54
|
+
<text fg={tokens.textDim}>click a prompt to edit its system prompt</text>
|
|
55
|
+
</box>
|
|
56
|
+
<box onMouseUp={onReset}>
|
|
57
|
+
<text fg={tokens.textDim}>reset to defaults ▸</text>
|
|
58
|
+
</box>
|
|
59
|
+
</box>
|
|
60
|
+
<box style={{ height: 1 }} />
|
|
61
|
+
<ScrollArea scrollRef={scrollRef}>
|
|
62
|
+
{actions.map((action, index) => (
|
|
63
|
+
<ActionRow
|
|
64
|
+
key={index}
|
|
65
|
+
rowId={`action-row-${index}`}
|
|
66
|
+
action={action}
|
|
67
|
+
isSelected={index === selectedIndex}
|
|
68
|
+
isExpanded={index === expandedIndex}
|
|
69
|
+
onToggleExpand={() => onToggleExpand(index)}
|
|
70
|
+
onEditMetadata={(metadata) => onEditMetadata(index, metadata)}
|
|
71
|
+
theme={theme}
|
|
72
|
+
/>
|
|
73
|
+
))}
|
|
74
|
+
<box
|
|
75
|
+
id={`action-row-${actions.length}`}
|
|
76
|
+
style={{ backgroundColor: selectedIndex === actions.length ? tokens.border : undefined }}
|
|
77
|
+
onMouseUp={onAdd}
|
|
78
|
+
>
|
|
79
|
+
<text fg={tokens.textMuted}>+ Add action</text>
|
|
80
|
+
</box>
|
|
81
|
+
</ScrollArea>
|
|
82
|
+
</box>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function ActionRow({
|
|
87
|
+
rowId,
|
|
88
|
+
action,
|
|
89
|
+
isSelected,
|
|
90
|
+
isExpanded,
|
|
91
|
+
onToggleExpand,
|
|
92
|
+
onEditMetadata,
|
|
93
|
+
theme,
|
|
94
|
+
}: {
|
|
95
|
+
rowId: string;
|
|
96
|
+
action: QuickAction;
|
|
97
|
+
isSelected: boolean;
|
|
98
|
+
isExpanded: boolean;
|
|
99
|
+
onToggleExpand: () => void;
|
|
100
|
+
onEditMetadata: (metadata: string) => void;
|
|
101
|
+
theme?: Theme;
|
|
102
|
+
}): React.ReactNode {
|
|
103
|
+
const tokens = useComponentTheme(theme);
|
|
104
|
+
const inputRef = useRef<TextareaRenderable | null>(null);
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<box id={rowId} style={{ flexDirection: "column" }}>
|
|
108
|
+
<box
|
|
109
|
+
style={{ backgroundColor: isSelected ? tokens.border : undefined }}
|
|
110
|
+
onMouseUp={onToggleExpand}
|
|
111
|
+
>
|
|
112
|
+
<text fg={isSelected ? tokens.text : tokens.textMuted}>
|
|
113
|
+
{`${isExpanded ? "▾ " : "▸ "}${action.prompt}`}
|
|
114
|
+
</text>
|
|
115
|
+
</box>
|
|
116
|
+
{isExpanded ? (
|
|
117
|
+
<box style={{ flexDirection: "row", paddingLeft: 2 }}>
|
|
118
|
+
<box style={{ width: 2, flexShrink: 0 }}>
|
|
119
|
+
<text fg={tokens.textDim}>{">"}</text>
|
|
120
|
+
</box>
|
|
121
|
+
<textarea
|
|
122
|
+
ref={inputRef}
|
|
123
|
+
focused
|
|
124
|
+
initialValue={action.metadata ?? ""}
|
|
125
|
+
placeholder="extra system prompt appended to this comment (optional)"
|
|
126
|
+
keyBindings={ACTION_INPUT_KEY_BINDINGS}
|
|
127
|
+
onContentChange={() => onEditMetadata(inputRef.current?.plainText ?? "")}
|
|
128
|
+
style={{
|
|
129
|
+
height: 1,
|
|
130
|
+
flexGrow: 1,
|
|
131
|
+
backgroundColor: tokens.elevated,
|
|
132
|
+
focusedBackgroundColor: tokens.elevated,
|
|
133
|
+
textColor: tokens.text,
|
|
134
|
+
focusedTextColor: tokens.text,
|
|
135
|
+
}}
|
|
136
|
+
/>
|
|
137
|
+
</box>
|
|
138
|
+
) : action.metadata ? (
|
|
139
|
+
<box style={{ paddingLeft: 2 }}>
|
|
140
|
+
<text fg={tokens.textDim}>{truncateMetadata(action.metadata)}</text>
|
|
141
|
+
</box>
|
|
142
|
+
) : null}
|
|
143
|
+
</box>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A one-line preview of the system prompt under a collapsed row. */
|
|
148
|
+
function truncateMetadata(metadata: string): string {
|
|
149
|
+
return metadata.length > 44 ? `${metadata.slice(0, 43)}…` : metadata;
|
|
150
|
+
}
|