pi-ui-extension 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -0
- package/extensions/ctx/README.md +35 -0
- package/extensions/ctx/activity.ts +226 -0
- package/extensions/ctx/data.test.ts +65 -0
- package/extensions/ctx/data.ts +230 -0
- package/extensions/ctx/panel.test.ts +103 -0
- package/extensions/ctx/panel.ts +185 -0
- package/extensions/ctx/tools.test.ts +64 -0
- package/extensions/fabric-detection.test.ts +15 -0
- package/extensions/fabric-detection.ts +7 -0
- package/extensions/footer/index.ts +62 -0
- package/extensions/index.ts +577 -0
- package/extensions/mcp/integration.test.ts +64 -0
- package/extensions/mcp/resources.test.ts +27 -0
- package/extensions/mcp/resources.ts +31 -0
- package/extensions/resource-dialog.test.ts +45 -0
- package/extensions/session/mutations.test.ts +30 -0
- package/extensions/session/mutations.ts +17 -0
- package/extensions/session/panel.test.ts +238 -0
- package/extensions/session/panel.ts +461 -0
- package/extensions/sidebar/default-panels.test.ts +97 -0
- package/extensions/sidebar/default-panels.ts +155 -0
- package/extensions/sidebar/index.ts +212 -0
- package/extensions/sidebar/layout.test.ts +68 -0
- package/extensions/sidebar/layout.ts +75 -0
- package/extensions/sidebar/panel.ts +49 -0
- package/extensions/sidebar/registry.ts +35 -0
- package/extensions/sidebar/resize.test.ts +71 -0
- package/extensions/sidebar/resize.ts +154 -0
- package/extensions/tool-view/controller.test.ts +57 -0
- package/extensions/tool-view/controller.ts +519 -0
- package/extensions/tool-view/panel.test.ts +73 -0
- package/extensions/tool-view/panel.ts +221 -0
- package/extensions/tool-view/trust.test.ts +41 -0
- package/extensions/tool-view/types.ts +6 -0
- package/extensions/tool-view/usage.test.ts +231 -0
- package/extensions/tool-view/usage.ts +303 -0
- package/extensions/tool-view/wheel.test.ts +32 -0
- package/package.json +53 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { ContextUsage, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { ScrollView, VStack, type Component } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { RunActivitySnapshot } from "../ctx/activity.ts";
|
|
4
|
+
import type { ContextEstimate } from "../ctx/data.ts";
|
|
5
|
+
import { ContextPanel } from "../ctx/panel.ts";
|
|
6
|
+
import type { ResourceButtonId } from "../session/panel.ts";
|
|
7
|
+
import { SessionPanel, type SessionListItem } from "../session/panel.ts";
|
|
8
|
+
import { ToolViewPanel } from "../tool-view/panel.ts";
|
|
9
|
+
import { panelBottom } from "./panel.ts";
|
|
10
|
+
import type { ToolViewMode } from "../tool-view/types.ts";
|
|
11
|
+
import type { ToolUsageSnapshot } from "../tool-view/usage.ts";
|
|
12
|
+
import type { SidebarPanelDefinition } from "./registry.ts";
|
|
13
|
+
|
|
14
|
+
export const SESSION_PANEL_ID = "sessions";
|
|
15
|
+
export const CONTEXT_PANEL_ID = "context";
|
|
16
|
+
export const TOOL_VIEW_PANEL_ID = "tool-view";
|
|
17
|
+
|
|
18
|
+
class ToolViewScrollView extends VStack {
|
|
19
|
+
readonly scrollView: ScrollView;
|
|
20
|
+
|
|
21
|
+
constructor(panel: ToolViewPanel, theme: Theme) {
|
|
22
|
+
const scrollView = new ScrollView(panel, {
|
|
23
|
+
follow: "none",
|
|
24
|
+
overscroll: "chain",
|
|
25
|
+
scrollbar: "hidden",
|
|
26
|
+
scrollbarTrackStyle: (text: string) => theme.fg("accent", text),
|
|
27
|
+
scrollbarThumbStyle: (text: string) => theme.fg("accent", text),
|
|
28
|
+
});
|
|
29
|
+
const bottomBorder: Component = {
|
|
30
|
+
render: (width) => [panelBottom(theme, width)],
|
|
31
|
+
invalidate(): void {},
|
|
32
|
+
};
|
|
33
|
+
super([
|
|
34
|
+
{ component: scrollView, grow: 1, shrink: 1, minSize: 0 },
|
|
35
|
+
{ component: bottomBorder, basis: 1, grow: 0, shrink: 0, minSize: 1 },
|
|
36
|
+
], { gap: 0, align: "stretch" });
|
|
37
|
+
this.scrollView = scrollView;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get scrollTop(): number { return this.scrollView.scrollTop; }
|
|
41
|
+
get isFollowingEnd(): boolean { return this.scrollView.isFollowingEnd; }
|
|
42
|
+
get viewportHeight(): number { return this.scrollView.viewportHeight; }
|
|
43
|
+
get scrollbar(): "hidden" | "auto" | "always" { return this.scrollView.scrollbar; }
|
|
44
|
+
updateLayout(contentHeight: number, viewportHeight: number, requestRender: () => void): void {
|
|
45
|
+
this.scrollView.updateLayout(contentHeight, viewportHeight, requestRender);
|
|
46
|
+
this.scrollView.setScrollbar(contentHeight > viewportHeight ? "always" : "hidden");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createToolViewScrollView(panel: ToolViewPanel, theme: Theme): ToolViewScrollView {
|
|
51
|
+
// Keep the panel identity and mode selector visible by default. Following
|
|
52
|
+
// the end makes a long usage list look like the Tool View disappeared,
|
|
53
|
+
// because its title and selector are scrolled out of the viewport.
|
|
54
|
+
return new ToolViewScrollView(panel, theme);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type DefaultSidebarPanelOptions = {
|
|
58
|
+
readonly getContextEstimate?: () => ContextEstimate;
|
|
59
|
+
readonly getContextDelta?: () => number | undefined;
|
|
60
|
+
readonly getActivity?: () => RunActivitySnapshot | undefined;
|
|
61
|
+
readonly getCacheHitRate?: () => number | undefined;
|
|
62
|
+
readonly getContextUsage: () => ContextUsage | undefined;
|
|
63
|
+
readonly getModel: () => string;
|
|
64
|
+
readonly getThinkingLevel: () => string;
|
|
65
|
+
readonly getToolViewMode: () => ToolViewMode | undefined;
|
|
66
|
+
readonly getToolUsage: () => ToolUsageSnapshot | undefined;
|
|
67
|
+
readonly onSelectToolViewMode: (mode: ToolViewMode) => void;
|
|
68
|
+
readonly showToolViewModeSelector?: () => boolean;
|
|
69
|
+
readonly loadSessions: () => Promise<readonly SessionListItem[]>;
|
|
70
|
+
readonly onSelectSession: (session: SessionListItem) => void;
|
|
71
|
+
readonly onRenameSession: (session: SessionListItem) => void;
|
|
72
|
+
readonly onDeleteSession: (session: SessionListItem) => void;
|
|
73
|
+
readonly onDeleteOthers: () => void;
|
|
74
|
+
readonly onOpenResources: (button: ResourceButtonId) => void;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
class AdaptiveScrollView extends ScrollView {
|
|
78
|
+
override updateLayout(contentHeight: number, viewportHeight: number, requestRender: () => void): void {
|
|
79
|
+
super.updateLayout(contentHeight, viewportHeight, requestRender);
|
|
80
|
+
this.setScrollbar(contentHeight > viewportHeight ? "always" : "hidden");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function createDefaultSidebarPanels(
|
|
85
|
+
options: DefaultSidebarPanelOptions,
|
|
86
|
+
): readonly SidebarPanelDefinition[] {
|
|
87
|
+
return [{
|
|
88
|
+
id: SESSION_PANEL_ID,
|
|
89
|
+
layout: { minSize: 4, maxSize: 9 },
|
|
90
|
+
create({ tui, theme, getDesiredWidth }) {
|
|
91
|
+
const panel = new SessionPanel(theme, {
|
|
92
|
+
tui,
|
|
93
|
+
getDesiredWidth,
|
|
94
|
+
loadSessions: options.loadSessions,
|
|
95
|
+
onSelect: options.onSelectSession,
|
|
96
|
+
onRename: options.onRenameSession,
|
|
97
|
+
onDelete: options.onDeleteSession,
|
|
98
|
+
onDeleteOthers: options.onDeleteOthers,
|
|
99
|
+
onOpenResources: options.onOpenResources,
|
|
100
|
+
});
|
|
101
|
+
panel.refresh();
|
|
102
|
+
return {
|
|
103
|
+
component: panel,
|
|
104
|
+
update(): void { panel.refresh(); },
|
|
105
|
+
dispose(): void { panel.dispose(); },
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
}, {
|
|
109
|
+
id: CONTEXT_PANEL_ID,
|
|
110
|
+
layout: { minSize: 10, maxSize: ({ terminalRows }) => Math.max(10, Math.floor(terminalRows * 0.35)) },
|
|
111
|
+
create({ tui, theme, getDesiredWidth }) {
|
|
112
|
+
const panel = new ContextPanel(theme, {
|
|
113
|
+
getEstimate: options.getContextEstimate,
|
|
114
|
+
getDelta: options.getContextDelta,
|
|
115
|
+
getActivity: options.getActivity,
|
|
116
|
+
now: () => Date.now(),
|
|
117
|
+
getCacheHitRate: options.getCacheHitRate,
|
|
118
|
+
getUsage: options.getContextUsage,
|
|
119
|
+
getModel: options.getModel,
|
|
120
|
+
getThinkingLevel: options.getThinkingLevel,
|
|
121
|
+
getDesiredWidth,
|
|
122
|
+
});
|
|
123
|
+
// A one-second tick keeps the running duration fresh; CTX now owns the
|
|
124
|
+
// activity rows that used to live in their own panel.
|
|
125
|
+
const renderTimer = setInterval(() => tui.requestRender(), 1_000);
|
|
126
|
+
renderTimer.unref?.();
|
|
127
|
+
return {
|
|
128
|
+
component: new AdaptiveScrollView(panel, { follow: "none", overscroll: "chain", scrollbar: "hidden" }),
|
|
129
|
+
dispose(): void { clearInterval(renderTimer); },
|
|
130
|
+
};
|
|
131
|
+
},
|
|
132
|
+
}, {
|
|
133
|
+
id: TOOL_VIEW_PANEL_ID,
|
|
134
|
+
layout: {
|
|
135
|
+
minSize: 3,
|
|
136
|
+
maxSize: ({ terminalRows }) => Math.max(3, Math.floor(terminalRows * 0.35)),
|
|
137
|
+
},
|
|
138
|
+
create({ tui, theme, getDesiredWidth }) {
|
|
139
|
+
const panel = new ToolViewPanel(theme, {
|
|
140
|
+
tui,
|
|
141
|
+
getDesiredWidth,
|
|
142
|
+
getMode: options.getToolViewMode,
|
|
143
|
+
getUsage: options.getToolUsage,
|
|
144
|
+
onSelect: options.onSelectToolViewMode,
|
|
145
|
+
showModeSelector: options.showToolViewModeSelector,
|
|
146
|
+
showBottomBorder: false,
|
|
147
|
+
});
|
|
148
|
+
const scroll = createToolViewScrollView(panel, theme);
|
|
149
|
+
return {
|
|
150
|
+
component: scroll,
|
|
151
|
+
dispose(): void { panel.dispose(); },
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
}];
|
|
155
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
HStack,
|
|
4
|
+
VStack,
|
|
5
|
+
isViewportTUI,
|
|
6
|
+
type Component,
|
|
7
|
+
type TUI,
|
|
8
|
+
type ViewportTUI,
|
|
9
|
+
} from "@earendil-works/pi-tui";
|
|
10
|
+
import { createSidebarScrollView } from "./layout.ts";
|
|
11
|
+
import { installMouseResize, SidebarDivider } from "./resize.ts";
|
|
12
|
+
import type {
|
|
13
|
+
SidebarPanelDefinition,
|
|
14
|
+
SidebarPanelInstance,
|
|
15
|
+
SidebarPanelLayout,
|
|
16
|
+
} from "./registry.ts";
|
|
17
|
+
|
|
18
|
+
const SIDEBAR_RATIO = 0.20;
|
|
19
|
+
const MIN_SPLIT_WIDTH = 120;
|
|
20
|
+
const MIN_SIDEBAR_WIDTH = 18;
|
|
21
|
+
const MAX_SIDEBAR_WIDTH = 72;
|
|
22
|
+
const MIN_MAIN_WIDTH = 64;
|
|
23
|
+
|
|
24
|
+
type LayoutTui = ViewportTUI & { layoutRoot?: Component };
|
|
25
|
+
type MutableHStack = HStack & { entries: Array<{ basis?: number | "auto" }> };
|
|
26
|
+
type MutableVStack = VStack & { entries: Array<{ maxSize?: number }> };
|
|
27
|
+
const RESET_STYLE = "\x1b[0m";
|
|
28
|
+
|
|
29
|
+
type MountedPanel = {
|
|
30
|
+
readonly definition: SidebarPanelDefinition<unknown>;
|
|
31
|
+
readonly instance: SidebarPanelInstance<unknown>;
|
|
32
|
+
readonly entry: MutableVStack["entries"][number] | undefined;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type SidebarController = {
|
|
36
|
+
readonly mounted: boolean;
|
|
37
|
+
updatePanel<State>(id: string, state: State): boolean;
|
|
38
|
+
refresh(): void;
|
|
39
|
+
dispose(): void;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function noopController(): SidebarController {
|
|
43
|
+
return {
|
|
44
|
+
mounted: false,
|
|
45
|
+
updatePanel(): boolean { return false; },
|
|
46
|
+
refresh(): void {},
|
|
47
|
+
dispose(): void {},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function resolveMaxSize(layout: SidebarPanelLayout | undefined, tui: TUI): number | undefined {
|
|
52
|
+
const maxSize = layout?.maxSize;
|
|
53
|
+
return typeof maxSize === "function"
|
|
54
|
+
? maxSize({ terminalRows: tui.terminal.rows, terminalColumns: tui.terminal.columns })
|
|
55
|
+
: maxSize;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function createLayoutEntry(
|
|
59
|
+
definition: SidebarPanelDefinition<unknown>,
|
|
60
|
+
component: Component,
|
|
61
|
+
tui: TUI,
|
|
62
|
+
): { component: Component; basis: number | "auto"; grow: number; shrink: number; minSize?: number; maxSize?: number } {
|
|
63
|
+
const layout = definition.layout;
|
|
64
|
+
const maxSize = resolveMaxSize(layout, tui);
|
|
65
|
+
return {
|
|
66
|
+
component,
|
|
67
|
+
basis: layout?.basis ?? "auto",
|
|
68
|
+
grow: layout?.grow ?? 0,
|
|
69
|
+
shrink: layout?.shrink ?? 0,
|
|
70
|
+
...(layout?.minSize === undefined ? {} : { minSize: layout.minSize }),
|
|
71
|
+
...(maxSize === undefined ? {} : { maxSize }),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function installSidebar(
|
|
76
|
+
tui: TUI,
|
|
77
|
+
theme: Theme,
|
|
78
|
+
definitions: readonly SidebarPanelDefinition[],
|
|
79
|
+
): SidebarController {
|
|
80
|
+
if (!isViewportTUI(tui)) return noopController();
|
|
81
|
+
|
|
82
|
+
// Pi currently exposes setLayoutRoot() but not getLayoutRoot() through the
|
|
83
|
+
// Extension API. In fullscreen mode the active root is present on ViewportTUI;
|
|
84
|
+
// fail closed if that implementation detail changes in a future Pi release.
|
|
85
|
+
const layoutTui = tui as LayoutTui;
|
|
86
|
+
const previousRoot = layoutTui.layoutRoot;
|
|
87
|
+
if (!previousRoot) return noopController();
|
|
88
|
+
|
|
89
|
+
let sidebarWidth = Math.max(MIN_SIDEBAR_WIDTH, Math.round(tui.terminal.columns * SIDEBAR_RATIO));
|
|
90
|
+
const clampSidebarWidth = (width: number): number => Math.min(
|
|
91
|
+
MAX_SIDEBAR_WIDTH,
|
|
92
|
+
Math.max(MIN_SIDEBAR_WIDTH, Math.min(Math.trunc(width), tui.terminal.columns - MIN_MAIN_WIDTH)),
|
|
93
|
+
);
|
|
94
|
+
sidebarWidth = clampSidebarWidth(sidebarWidth);
|
|
95
|
+
const getSidebarWidth = () => sidebarWidth;
|
|
96
|
+
const mountedPanels = new Map<string, MountedPanel>();
|
|
97
|
+
const createdInstances: SidebarPanelInstance<unknown>[] = [];
|
|
98
|
+
let layoutEntries: Array<{
|
|
99
|
+
definition: SidebarPanelDefinition<unknown>;
|
|
100
|
+
instance: SidebarPanelInstance<unknown>;
|
|
101
|
+
layoutEntry: ReturnType<typeof createLayoutEntry>;
|
|
102
|
+
}>;
|
|
103
|
+
try {
|
|
104
|
+
layoutEntries = definitions.map((definition) => {
|
|
105
|
+
const instance = definition.create({ tui, theme, getDesiredWidth: getSidebarWidth });
|
|
106
|
+
createdInstances.push(instance);
|
|
107
|
+
return { definition, instance, layoutEntry: createLayoutEntry(definition, instance.component, tui) };
|
|
108
|
+
});
|
|
109
|
+
} catch (error) {
|
|
110
|
+
for (const instance of createdInstances.reverse()) {
|
|
111
|
+
try { instance.dispose?.(); } catch { /* Preserve the original creation error. */ }
|
|
112
|
+
}
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
const separators = layoutEntries.slice(1).map((): Component => ({
|
|
116
|
+
render: (width) => [`${RESET_STYLE}${" ".repeat(Math.max(1, width))}`],
|
|
117
|
+
invalidate(): void {},
|
|
118
|
+
}));
|
|
119
|
+
const sidebar = new VStack(
|
|
120
|
+
layoutEntries.flatMap(({ layoutEntry }, index) => index === 0
|
|
121
|
+
? [layoutEntry]
|
|
122
|
+
: [{ component: separators[index - 1]!, basis: 1, grow: 0, shrink: 0 }, layoutEntry]),
|
|
123
|
+
{ gap: 0, align: "stretch" },
|
|
124
|
+
);
|
|
125
|
+
const sidebarScroll = createSidebarScrollView(sidebar, theme, () => tui.terminal.rows);
|
|
126
|
+
const sidebarEntries = (sidebar as MutableVStack).entries;
|
|
127
|
+
layoutEntries.forEach(({ definition, instance }, index) => {
|
|
128
|
+
mountedPanels.set(definition.id, { definition, instance, entry: sidebarEntries[index * 2] });
|
|
129
|
+
});
|
|
130
|
+
function syncPanelLayout(): void {
|
|
131
|
+
for (const panel of mountedPanels.values()) {
|
|
132
|
+
const maxSize = resolveMaxSize(panel.definition.layout, tui);
|
|
133
|
+
if (panel.entry && maxSize !== undefined) panel.entry.maxSize = maxSize;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const divider = new SidebarDivider(
|
|
137
|
+
(line) => theme.fg("dim", line),
|
|
138
|
+
() => tui.terminal.rows,
|
|
139
|
+
);
|
|
140
|
+
const splitRoot = new HStack([
|
|
141
|
+
{ component: previousRoot, basis: 0, grow: 1, shrink: 1, minSize: MIN_MAIN_WIDTH },
|
|
142
|
+
{
|
|
143
|
+
component: divider,
|
|
144
|
+
basis: 1,
|
|
145
|
+
grow: 0,
|
|
146
|
+
shrink: 0,
|
|
147
|
+
minSize: 1,
|
|
148
|
+
visible: ({ width }) => width >= MIN_SPLIT_WIDTH,
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
component: sidebarScroll,
|
|
152
|
+
basis: sidebarWidth,
|
|
153
|
+
grow: 0,
|
|
154
|
+
shrink: 0,
|
|
155
|
+
minSize: MIN_SIDEBAR_WIDTH,
|
|
156
|
+
maxSize: MAX_SIDEBAR_WIDTH,
|
|
157
|
+
visible: ({ width }) => width >= MIN_SPLIT_WIDTH,
|
|
158
|
+
},
|
|
159
|
+
]);
|
|
160
|
+
const sidebarEntry = (splitRoot as MutableHStack).entries[2];
|
|
161
|
+
const applySidebarWidth = (width: number): void => {
|
|
162
|
+
const next = clampSidebarWidth(width);
|
|
163
|
+
if (next === sidebarWidth || !sidebarEntry) return;
|
|
164
|
+
sidebarWidth = next;
|
|
165
|
+
sidebarEntry.basis = next;
|
|
166
|
+
tui.requestRender();
|
|
167
|
+
};
|
|
168
|
+
layoutTui.setLayoutRoot(splitRoot);
|
|
169
|
+
const previewDivider = new SidebarDivider(
|
|
170
|
+
(line) => theme.fg("accent", line),
|
|
171
|
+
() => tui.terminal.rows,
|
|
172
|
+
);
|
|
173
|
+
const uninstallMouseResize = installMouseResize({
|
|
174
|
+
tui,
|
|
175
|
+
preview: previewDivider,
|
|
176
|
+
isVisible: () => tui.terminal.columns >= MIN_SPLIT_WIDTH,
|
|
177
|
+
getDividerX: () => tui.terminal.columns - sidebarWidth,
|
|
178
|
+
clampSidebarWidth,
|
|
179
|
+
commitSidebarWidth: applySidebarWidth,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
let disposed = false;
|
|
183
|
+
return {
|
|
184
|
+
mounted: true,
|
|
185
|
+
updatePanel<State>(id: string, state: State): boolean {
|
|
186
|
+
if (disposed) return false;
|
|
187
|
+
const panel = mountedPanels.get(id.trim());
|
|
188
|
+
if (!panel?.instance.update) return false;
|
|
189
|
+
panel.instance.update(state);
|
|
190
|
+
tui.requestRender();
|
|
191
|
+
return true;
|
|
192
|
+
},
|
|
193
|
+
refresh(): void {
|
|
194
|
+
if (disposed) return;
|
|
195
|
+
syncPanelLayout();
|
|
196
|
+
for (const panel of mountedPanels.values()) panel.instance.refresh?.();
|
|
197
|
+
tui.requestRender();
|
|
198
|
+
},
|
|
199
|
+
dispose(): void {
|
|
200
|
+
if (disposed) return;
|
|
201
|
+
disposed = true;
|
|
202
|
+
uninstallMouseResize();
|
|
203
|
+
for (const panel of mountedPanels.values()) {
|
|
204
|
+
try { panel.instance.dispose?.(); } catch { /* Continue releasing the remaining panels. */ }
|
|
205
|
+
}
|
|
206
|
+
mountedPanels.clear();
|
|
207
|
+
if (layoutTui.layoutRoot === splitRoot) {
|
|
208
|
+
layoutTui.setLayoutRoot(previousRoot);
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { ScrollView, VStack, type Component } from "@earendil-works/pi-tui";
|
|
5
|
+
import { getScrollViewsAt, renderLayoutFrame } from "@earendil-works/pi-tui/dist/layout.js";
|
|
6
|
+
import { createSidebarScrollView } from "./layout.ts";
|
|
7
|
+
|
|
8
|
+
const component: Component = {
|
|
9
|
+
render: () => Array.from({ length: 20 }, (_, index) => `line ${index + 1}`),
|
|
10
|
+
invalidate(): void {},
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const theme = {
|
|
14
|
+
fg: (_color: string, text: string) => text,
|
|
15
|
+
} as Theme;
|
|
16
|
+
|
|
17
|
+
test("sidebar fills rows below its content so main-view backgrounds cannot leak through", () => {
|
|
18
|
+
const shortComponent: Component = {
|
|
19
|
+
render: () => ["content"],
|
|
20
|
+
invalidate(): void {},
|
|
21
|
+
};
|
|
22
|
+
const scroll = createSidebarScrollView(shortComponent, theme, () => 4);
|
|
23
|
+
const lines = scroll.render(8);
|
|
24
|
+
|
|
25
|
+
assert.equal(lines.length, 4);
|
|
26
|
+
assert.equal(lines[0], "content");
|
|
27
|
+
assert.deepEqual(lines.slice(1), Array(3).fill("\x1b[0m "));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("sidebar does not add padding beyond the terminal viewport", () => {
|
|
31
|
+
const scroll = createSidebarScrollView(component, theme, () => 5);
|
|
32
|
+
assert.equal(scroll.render(8).length, 20);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("sidebar layout preserves nested panel scroll views for wheel hit testing", () => {
|
|
36
|
+
const activityScroll = new ScrollView(component);
|
|
37
|
+
const sidebar = new VStack([
|
|
38
|
+
{ component: activityScroll, maxSize: 5 },
|
|
39
|
+
]);
|
|
40
|
+
const sidebarScroll = createSidebarScrollView(sidebar, theme, () => 10);
|
|
41
|
+
const frame = renderLayoutFrame(sidebarScroll, 8, 5, () => {});
|
|
42
|
+
|
|
43
|
+
assert.deepEqual(getScrollViewsAt(frame, 1, 1), [activityScroll, sidebarScroll]);
|
|
44
|
+
assert.equal(activityScroll.viewportHeight, 5);
|
|
45
|
+
assert.equal(activityScroll.scrollBy(3), 0);
|
|
46
|
+
assert.equal(activityScroll.scrollTop, 3);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("sidebar scroll view exposes overflowing content through vertical scrolling", () => {
|
|
50
|
+
const scroll = createSidebarScrollView(component, theme);
|
|
51
|
+
scroll.updateLayout(20, 5, () => {});
|
|
52
|
+
|
|
53
|
+
assert.equal(scroll.viewportHeight, 5);
|
|
54
|
+
assert.equal(scroll.scrollTop, 0);
|
|
55
|
+
assert.equal(scroll.scrollBy(3), 0);
|
|
56
|
+
assert.equal(scroll.scrollTop, 3);
|
|
57
|
+
assert.equal(scroll.scrollBy(100), 88);
|
|
58
|
+
assert.equal(scroll.scrollTop, 15);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("sidebar scroll view does not follow new content to the bottom", () => {
|
|
62
|
+
const scroll = createSidebarScrollView(component, theme);
|
|
63
|
+
scroll.updateLayout(20, 5, () => {});
|
|
64
|
+
scroll.scrollBy(4);
|
|
65
|
+
scroll.updateLayout(30, 5, () => {});
|
|
66
|
+
|
|
67
|
+
assert.equal(scroll.scrollTop, 4);
|
|
68
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { ScrollView, type Component } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
const LAYOUT_NODE = Symbol.for("@earendil-works/pi-tui/layout-node");
|
|
5
|
+
const RESET_STYLE = "\x1b[0m";
|
|
6
|
+
|
|
7
|
+
class ViewportPadding implements Component {
|
|
8
|
+
constructor(
|
|
9
|
+
private readonly component: Component,
|
|
10
|
+
private readonly getViewportHeight: () => number,
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
render(width: number): string[] {
|
|
14
|
+
const contentHeight = this.component.render(width).length;
|
|
15
|
+
const viewportHeight = Math.max(0, Math.floor(this.getViewportHeight()));
|
|
16
|
+
const paddingHeight = Math.max(0, viewportHeight - contentHeight);
|
|
17
|
+
const blankLine = `${RESET_STYLE}${" ".repeat(Math.max(1, width))}`;
|
|
18
|
+
return Array.from({ length: paddingHeight }, () => blankLine);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
invalidate(): void {}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
class ViewportHeightContent implements Component {
|
|
25
|
+
private readonly padding: ViewportPadding;
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
private readonly component: Component,
|
|
29
|
+
private readonly getViewportHeight: () => number,
|
|
30
|
+
) {
|
|
31
|
+
this.padding = new ViewportPadding(component, getViewportHeight);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
render(width: number): string[] {
|
|
35
|
+
const lines = this.component.render(width);
|
|
36
|
+
const viewportHeight = Math.max(0, Math.floor(this.getViewportHeight()));
|
|
37
|
+
if (lines.length >= viewportHeight) return lines;
|
|
38
|
+
|
|
39
|
+
const blankLine = `${RESET_STYLE}${" ".repeat(Math.max(1, width))}`;
|
|
40
|
+
return [
|
|
41
|
+
...lines,
|
|
42
|
+
...Array.from({ length: viewportHeight - lines.length }, () => blankLine),
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
invalidate(): void {
|
|
47
|
+
this.component.invalidate();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
[LAYOUT_NODE]() {
|
|
51
|
+
return {
|
|
52
|
+
type: "vstack",
|
|
53
|
+
entries: [
|
|
54
|
+
{ component: this.component },
|
|
55
|
+
{ component: this.padding },
|
|
56
|
+
],
|
|
57
|
+
gap: 0,
|
|
58
|
+
align: "stretch",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function createSidebarScrollView(
|
|
64
|
+
component: Component,
|
|
65
|
+
theme: Theme,
|
|
66
|
+
getViewportHeight: () => number = () => 0,
|
|
67
|
+
): ScrollView {
|
|
68
|
+
return new ScrollView(new ViewportHeightContent(component, getViewportHeight), {
|
|
69
|
+
follow: "none",
|
|
70
|
+
overscroll: "contain",
|
|
71
|
+
scrollbar: "auto",
|
|
72
|
+
scrollbarTrackStyle: (text: string) => theme.fg("accent", text),
|
|
73
|
+
scrollbarThumbStyle: (text: string) => theme.fg("accent", text),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
export type PanelColor = "success" | "error" | "warning" | "dim" | "accent";
|
|
5
|
+
|
|
6
|
+
export function panelTop(theme: Theme, title: string, renderWidth: number): string {
|
|
7
|
+
const innerWidth = Math.max(1, renderWidth - 2);
|
|
8
|
+
const titleText = ` ${title} `;
|
|
9
|
+
const borderWidth = Math.max(0, innerWidth - visibleWidth(titleText));
|
|
10
|
+
const left = Math.floor(borderWidth / 2);
|
|
11
|
+
const right = borderWidth - left;
|
|
12
|
+
return theme.fg("accent", `╭${"─".repeat(left)}${titleText}${"─".repeat(right)}╮`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function panelBottom(theme: Theme, renderWidth: number): string {
|
|
16
|
+
return theme.fg("accent", `╰${"─".repeat(Math.max(1, renderWidth - 2))}╯`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function panelContent(
|
|
20
|
+
theme: Theme,
|
|
21
|
+
content: string,
|
|
22
|
+
color: PanelColor,
|
|
23
|
+
contentWidth: number,
|
|
24
|
+
): string {
|
|
25
|
+
const text = truncateToWidth(content, contentWidth, "");
|
|
26
|
+
const padding = " ".repeat(Math.max(0, contentWidth - visibleWidth(text)));
|
|
27
|
+
return theme.fg("dim", "│ ") +
|
|
28
|
+
theme.fg(color, text) +
|
|
29
|
+
theme.fg("dim", `${padding} │`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function panelRow(
|
|
33
|
+
theme: Theme,
|
|
34
|
+
label: string,
|
|
35
|
+
value: string,
|
|
36
|
+
color: PanelColor,
|
|
37
|
+
contentWidth: number,
|
|
38
|
+
): string {
|
|
39
|
+
const labelWidth = Math.max(1, Math.min(11, Math.floor(contentWidth * 0.58)));
|
|
40
|
+
const valueWidth = Math.max(1, contentWidth - labelWidth - 1);
|
|
41
|
+
const labelText = truncateToWidth(label, labelWidth, "").padEnd(labelWidth, " ");
|
|
42
|
+
const valueText = truncateToWidth(value, valueWidth, "");
|
|
43
|
+
const usedWidth = visibleWidth(labelText) + 1 + visibleWidth(valueText);
|
|
44
|
+
const padding = " ".repeat(Math.max(0, contentWidth - usedWidth));
|
|
45
|
+
return theme.fg("dim", "│ ") +
|
|
46
|
+
theme.fg("dim", `${labelText} `) +
|
|
47
|
+
theme.fg(color, valueText) +
|
|
48
|
+
theme.fg("dim", `${padding} │`);
|
|
49
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
export type SidebarPanelLayoutContext = {
|
|
5
|
+
readonly terminalRows: number;
|
|
6
|
+
readonly terminalColumns: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type SidebarPanelLayout = {
|
|
10
|
+
readonly basis?: number | "auto";
|
|
11
|
+
readonly grow?: number;
|
|
12
|
+
readonly shrink?: number;
|
|
13
|
+
readonly minSize?: number;
|
|
14
|
+
readonly maxSize?: number | ((context: SidebarPanelLayoutContext) => number);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type SidebarPanelContext = {
|
|
18
|
+
readonly tui: TUI;
|
|
19
|
+
readonly theme: Theme;
|
|
20
|
+
readonly getDesiredWidth: () => number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type SidebarPanelInstance<State = unknown> = {
|
|
24
|
+
readonly component: Component;
|
|
25
|
+
update?(state: State): void;
|
|
26
|
+
refresh?(): void;
|
|
27
|
+
dispose?(): void;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type SidebarPanelDefinition<State = unknown> = {
|
|
31
|
+
readonly id: string;
|
|
32
|
+
readonly layout?: SidebarPanelLayout;
|
|
33
|
+
create(context: SidebarPanelContext): SidebarPanelInstance<State>;
|
|
34
|
+
};
|
|
35
|
+
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
4
|
+
import { installSidebar } from "./index.ts";
|
|
5
|
+
import type { SidebarPanelDefinition } from "./registry.ts";
|
|
6
|
+
|
|
7
|
+
test("sidebar ignores hidden divider hits and cancels drags after terminal resize", async () => {
|
|
8
|
+
let overlays = 0, hidden = 0;
|
|
9
|
+
const listeners = new Set<any>();
|
|
10
|
+
const root = { render: () => [], invalidate() {} };
|
|
11
|
+
const tui: any = {
|
|
12
|
+
[Symbol.for("@earendil-works/pi-tui/viewport")]: true,
|
|
13
|
+
terminal: { columns: 100, rows: 40 }, layoutRoot: root,
|
|
14
|
+
inputListeners: listeners,
|
|
15
|
+
addInputListener(fn: any) { listeners.add(fn); return () => listeners.delete(fn); },
|
|
16
|
+
setLayoutRoot(next: any) { this.layoutRoot = next; },
|
|
17
|
+
requestRender() {},
|
|
18
|
+
showOverlay() { overlays++; return { hide() { hidden++; } }; },
|
|
19
|
+
};
|
|
20
|
+
const controller = installSidebar(tui, { fg: (_: string, text: string) => text } as any, []);
|
|
21
|
+
const input = (data: string) => [...listeners][0]?.(data);
|
|
22
|
+
try {
|
|
23
|
+
assert.equal(controller.mounted, true);
|
|
24
|
+
assert.equal(input("\x1b[<0;80;5M"), undefined);
|
|
25
|
+
assert.equal(overlays, 0);
|
|
26
|
+
tui.terminal.columns = 160;
|
|
27
|
+
assert.deepEqual(input("\x1b[<0;140;5M"), { consume: true });
|
|
28
|
+
assert.deepEqual(input("\x1b[<0;130;5m"), { consume: true });
|
|
29
|
+
assert.equal(tui.layoutRoot.entries[2].basis, 30);
|
|
30
|
+
assert.equal(hidden, 1);
|
|
31
|
+
assert.deepEqual(input("\x1b[<0;130;5M"), { consume: true });
|
|
32
|
+
tui.terminal.columns = 100;
|
|
33
|
+
await delay(50);
|
|
34
|
+
assert.equal(hidden, 2);
|
|
35
|
+
assert.equal(input("\x1b[<0;70;5m"), undefined);
|
|
36
|
+
assert.equal(tui.layoutRoot.entries[2].basis, 30);
|
|
37
|
+
tui.terminal.columns = 160;
|
|
38
|
+
input("\x1b[<0;130;5M");
|
|
39
|
+
tui.terminal.rows = 30;
|
|
40
|
+
assert.equal(input("\x1b[<0;120;5m"), undefined);
|
|
41
|
+
assert.equal(hidden, 3);
|
|
42
|
+
assert.equal(tui.layoutRoot.entries[2].basis, 30);
|
|
43
|
+
} finally { controller.dispose(); }
|
|
44
|
+
assert.equal(listeners.size, 0);
|
|
45
|
+
assert.equal(tui.layoutRoot, root);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("sidebar paints separator rows instead of leaving stale main-view colors", () => {
|
|
49
|
+
const root = { render: () => [], invalidate() {} };
|
|
50
|
+
const tui: any = {
|
|
51
|
+
[Symbol.for("@earendil-works/pi-tui/viewport")]: true,
|
|
52
|
+
terminal: { columns: 160, rows: 6 }, layoutRoot: root,
|
|
53
|
+
inputListeners: new Set(),
|
|
54
|
+
addInputListener() { return () => {}; },
|
|
55
|
+
setLayoutRoot(next: any) { this.layoutRoot = next; },
|
|
56
|
+
requestRender() {},
|
|
57
|
+
};
|
|
58
|
+
const panel = (id: string): SidebarPanelDefinition => ({
|
|
59
|
+
id,
|
|
60
|
+
create: () => ({ component: { render: () => [id], invalidate() {} } }),
|
|
61
|
+
});
|
|
62
|
+
const controller = installSidebar(
|
|
63
|
+
tui,
|
|
64
|
+
{ fg: (_: string, text: string) => text } as any,
|
|
65
|
+
[panel("one"), panel("two")],
|
|
66
|
+
);
|
|
67
|
+
try {
|
|
68
|
+
const lines = tui.layoutRoot.entries[2].component.render(32);
|
|
69
|
+
assert.equal(lines[1], `\x1b[0m${" ".repeat(32)}`);
|
|
70
|
+
} finally { controller.dispose(); }
|
|
71
|
+
});
|