@thoth-agents/pi-thoth-theme 0.1.0 → 0.2.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 +11 -5
- package/dist/index.ts +3686 -0
- package/package.json +7 -9
- package/src/index.ts +0 -53
- package/src/input-box/decorate.ts +0 -304
- package/src/input-box/frame.ts +0 -182
- package/src/input-box/gradient.ts +0 -92
- package/src/input-box/state.ts +0 -90
- package/src/render-kit/index.ts +0 -305
- package/src/render-kit/working.ts +0 -25
- package/src/shared/cache.ts +0 -21
- package/src/shared/config.ts +0 -86
- package/src/shared/duration.ts +0 -1
- package/src/shared/icons.ts +0 -89
- package/src/status-line/cost.ts +0 -56
- package/src/status-line/index.ts +0 -242
- package/src/status-line/layout.ts +0 -272
- package/src/status-line/snapshot.ts +0 -11
- package/src/status-line/throughput.ts +0 -61
- package/src/status-line/tokens.ts +0 -51
- package/src/tools/bash.ts +0 -237
- package/src/tools/border.ts +0 -21
- package/src/tools/box.ts +0 -258
- package/src/tools/edit.ts +0 -228
- package/src/tools/file-icons.ts +0 -139
- package/src/tools/find.ts +0 -227
- package/src/tools/frame.ts +0 -166
- package/src/tools/generic.ts +0 -195
- package/src/tools/grep.ts +0 -371
- package/src/tools/index.ts +0 -135
- package/src/tools/ls.ts +0 -231
- package/src/tools/ownership.ts +0 -70
- package/src/tools/powershell.ts +0 -1
- package/src/tools/read.ts +0 -194
- package/src/tools/ticker.ts +0 -101
- package/src/tools/write.ts +0 -188
- package/src/welcome/age.ts +0 -11
- package/src/welcome/index.ts +0 -132
- package/src/welcome/logo.ts +0 -35
- package/src/welcome/render.ts +0 -323
- package/src/welcome/resources.ts +0 -251
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thoth-agents/pi-thoth-theme",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Modular Thoth theme, status line, tool styling, and welcome header for Pi.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
],
|
|
17
17
|
"pi": {
|
|
18
18
|
"extensions": [
|
|
19
|
-
"./
|
|
19
|
+
"./dist/index.ts"
|
|
20
20
|
],
|
|
21
21
|
"themes": [
|
|
22
22
|
"./themes"
|
|
@@ -31,8 +31,7 @@
|
|
|
31
31
|
"access": "public"
|
|
32
32
|
},
|
|
33
33
|
"files": [
|
|
34
|
-
"
|
|
35
|
-
"!src/**/*.test.ts",
|
|
34
|
+
"dist",
|
|
36
35
|
"themes/**",
|
|
37
36
|
"README.md",
|
|
38
37
|
"LICENSE"
|
|
@@ -50,17 +49,16 @@
|
|
|
50
49
|
"@earendil-works/pi-coding-agent": "1.0.2",
|
|
51
50
|
"@earendil-works/pi-tui": "1.0.2",
|
|
52
51
|
"@earendil-works/pi-ai": "1.0.2",
|
|
53
|
-
"@rolldown/binding-wasm32-wasi": "^1.2.4"
|
|
52
|
+
"@rolldown/binding-wasm32-wasi": "^1.2.4",
|
|
53
|
+
"@thoth-agents/pi-core": "^0.2.0"
|
|
54
54
|
},
|
|
55
55
|
"upstream": {
|
|
56
56
|
"repository": "https://github.com/QuangThai/pi-omp-theme",
|
|
57
57
|
"role": "primary source"
|
|
58
58
|
},
|
|
59
|
-
"dependencies": {
|
|
60
|
-
"@thoth-agents/pi-core": "^0.1.0"
|
|
61
|
-
},
|
|
62
59
|
"scripts": {
|
|
63
60
|
"typecheck": "tsc --noEmit",
|
|
64
|
-
"test": "vitest run"
|
|
61
|
+
"test": "vitest run",
|
|
62
|
+
"build": "node ../../scripts/build-pi-extensions.mjs pi-thoth-theme"
|
|
65
63
|
}
|
|
66
64
|
}
|
package/src/index.ts
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
|
-
import {
|
|
3
|
-
type RenderKitToken,
|
|
4
|
-
registerRenderKit,
|
|
5
|
-
withdrawRenderKit,
|
|
6
|
-
} from '@thoth-agents/pi-core';
|
|
7
|
-
import { createRenderKit } from './render-kit/index.ts';
|
|
8
|
-
import { loadConfig } from './shared/config.ts';
|
|
9
|
-
import { frames, icon } from './shared/icons.ts';
|
|
10
|
-
import { registerStatusLine } from './status-line/index.ts';
|
|
11
|
-
import { createToolRendererResolver, registerTools } from './tools/index.ts';
|
|
12
|
-
import { registerWelcome } from './welcome/index.ts';
|
|
13
|
-
|
|
14
|
-
export default function thothTheme(pi: ExtensionAPI): void {
|
|
15
|
-
const config = loadConfig();
|
|
16
|
-
const owner = {};
|
|
17
|
-
const cwd = process.cwd();
|
|
18
|
-
const resolveToolRenderers = config.tools.enabled
|
|
19
|
-
? createToolRendererResolver(pi, config, cwd)
|
|
20
|
-
: undefined;
|
|
21
|
-
let kitToken: RenderKitToken | undefined;
|
|
22
|
-
if (resolveToolRenderers) {
|
|
23
|
-
registerTools(pi, config, cwd, owner, resolveToolRenderers);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
pi.on('session_shutdown', (_event, ctx) => {
|
|
27
|
-
if (!ctx.hasUI || kitToken === undefined) return;
|
|
28
|
-
withdrawRenderKit(kitToken);
|
|
29
|
-
kitToken = undefined;
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
pi.on('session_start', (_event, ctx) => {
|
|
33
|
-
if (!ctx.hasUI) return;
|
|
34
|
-
if (config.tools.enabled) {
|
|
35
|
-
kitToken = registerRenderKit(
|
|
36
|
-
createRenderKit(owner, resolveToolRenderers, config.icons),
|
|
37
|
-
owner,
|
|
38
|
-
);
|
|
39
|
-
}
|
|
40
|
-
if (!ctx.ui) return;
|
|
41
|
-
if (config.statusLine.enabled) {
|
|
42
|
-
if (config.inputBox?.enabled !== false) {
|
|
43
|
-
ctx.ui.setWorkingIndicator?.({
|
|
44
|
-
frames: [...frames('workingFrames', config.icons)],
|
|
45
|
-
intervalMs: 200,
|
|
46
|
-
});
|
|
47
|
-
ctx.ui.setWorkingMessage?.(`working${icon('ellipsis', config.icons)}`);
|
|
48
|
-
}
|
|
49
|
-
registerStatusLine(pi, ctx, config);
|
|
50
|
-
}
|
|
51
|
-
if (config.welcome.enabled) registerWelcome(pi, ctx, config);
|
|
52
|
-
});
|
|
53
|
-
}
|
|
@@ -1,304 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type TuiMouseEvent,
|
|
3
|
-
type TuiMouseEventResult,
|
|
4
|
-
truncateToWidth,
|
|
5
|
-
visibleWidth,
|
|
6
|
-
} from '@earendil-works/pi-tui';
|
|
7
|
-
import type { IconMode } from '../shared/config.ts';
|
|
8
|
-
import {
|
|
9
|
-
type ActiveThemeLike,
|
|
10
|
-
formatBranchSegment,
|
|
11
|
-
formatContextSegments,
|
|
12
|
-
formatCwdSegments,
|
|
13
|
-
formatModelSegments,
|
|
14
|
-
} from '../status-line/layout.ts';
|
|
15
|
-
import type {
|
|
16
|
-
StatusSnapshot,
|
|
17
|
-
StatusSnapshotProvider,
|
|
18
|
-
} from '../status-line/snapshot.ts';
|
|
19
|
-
import {
|
|
20
|
-
inputLabelWidth,
|
|
21
|
-
renderInputBottom,
|
|
22
|
-
renderInputTop,
|
|
23
|
-
renderPlaceholder,
|
|
24
|
-
scrollLabel,
|
|
25
|
-
wrapContentRow,
|
|
26
|
-
} from './frame.ts';
|
|
27
|
-
import { type BreathingFrame, createBreathingFrame } from './gradient.ts';
|
|
28
|
-
import type { WorkingState } from './state.ts';
|
|
29
|
-
|
|
30
|
-
export const MIN_INPUT_BOX_WIDTH = 16;
|
|
31
|
-
|
|
32
|
-
interface EditorLike {
|
|
33
|
-
render(width: number): string[];
|
|
34
|
-
renderTopBorder(width: number, hiddenLineCount: number): string;
|
|
35
|
-
renderBottomBorder(width: number, hiddenLineCount: number): string;
|
|
36
|
-
getText(): string;
|
|
37
|
-
getPaddingX(): number;
|
|
38
|
-
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined;
|
|
39
|
-
borderColor(text: string): string;
|
|
40
|
-
workingStatusIndicator?: unknown;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export interface InputBoxDeps {
|
|
44
|
-
theme: ActiveThemeLike;
|
|
45
|
-
working: WorkingState;
|
|
46
|
-
/** Omission retains Unicode glyphs; may be a live getter. */
|
|
47
|
-
readonly iconMode?: IconMode;
|
|
48
|
-
/** Shared live data; optional for standalone decorator consumers. */
|
|
49
|
-
getStatusSnapshot?: StatusSnapshotProvider;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export interface EditorDecoration {
|
|
53
|
-
getStatusSnapshot(): Readonly<StatusSnapshot> | undefined;
|
|
54
|
-
dispose(): void;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const decoratedEditors = new WeakSet<object>();
|
|
58
|
-
const decorations = new WeakMap<object, EditorDecoration>();
|
|
59
|
-
const methodNames = [
|
|
60
|
-
'render',
|
|
61
|
-
'renderTopBorder',
|
|
62
|
-
'renderBottomBorder',
|
|
63
|
-
'getText',
|
|
64
|
-
'getPaddingX',
|
|
65
|
-
'handleMouse',
|
|
66
|
-
'borderColor',
|
|
67
|
-
] as const;
|
|
68
|
-
|
|
69
|
-
// Do not read editor.render: the background-task Proxy getter dynamically calls
|
|
70
|
-
// target.render, which would recurse after replacing that same target method.
|
|
71
|
-
function method<Name extends keyof EditorLike>(
|
|
72
|
-
editor: object,
|
|
73
|
-
name: Name,
|
|
74
|
-
): EditorLike[Name] | undefined {
|
|
75
|
-
for (
|
|
76
|
-
let owner: object | null = editor;
|
|
77
|
-
owner;
|
|
78
|
-
owner = Reflect.getPrototypeOf(owner)
|
|
79
|
-
) {
|
|
80
|
-
const descriptor = Reflect.getOwnPropertyDescriptor(owner, name);
|
|
81
|
-
if (descriptor)
|
|
82
|
-
return typeof descriptor.value === 'function'
|
|
83
|
-
? descriptor.value
|
|
84
|
-
: undefined;
|
|
85
|
-
}
|
|
86
|
-
return undefined;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export function isEditorLike(editor: unknown): editor is EditorLike {
|
|
90
|
-
if (!editor || typeof editor !== 'object') return false;
|
|
91
|
-
if (decoratedEditors.has(editor)) return true;
|
|
92
|
-
try {
|
|
93
|
-
return methodNames.every((name) => method(editor, name) !== undefined);
|
|
94
|
-
} catch {
|
|
95
|
-
return false;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export function decorateEditor(
|
|
100
|
-
editor: unknown,
|
|
101
|
-
deps: InputBoxDeps,
|
|
102
|
-
): EditorDecoration | undefined {
|
|
103
|
-
if (!isEditorLike(editor)) return undefined;
|
|
104
|
-
if (decoratedEditors.has(editor)) return decorations.get(editor);
|
|
105
|
-
const names = [
|
|
106
|
-
'render',
|
|
107
|
-
'renderTopBorder',
|
|
108
|
-
'renderBottomBorder',
|
|
109
|
-
'handleMouse',
|
|
110
|
-
'borderColor',
|
|
111
|
-
] as const;
|
|
112
|
-
if (
|
|
113
|
-
!Reflect.isExtensible(editor) ||
|
|
114
|
-
names.some(
|
|
115
|
-
(name) =>
|
|
116
|
-
Reflect.getOwnPropertyDescriptor(editor, name)?.configurable === false,
|
|
117
|
-
)
|
|
118
|
-
)
|
|
119
|
-
return undefined;
|
|
120
|
-
const originals = Object.fromEntries(
|
|
121
|
-
methodNames.map((name) => [name, method(editor, name)]),
|
|
122
|
-
) as unknown as EditorLike;
|
|
123
|
-
const descriptors = new Map(
|
|
124
|
-
names.map((name) => [name, Reflect.getOwnPropertyDescriptor(editor, name)]),
|
|
125
|
-
);
|
|
126
|
-
let active = true;
|
|
127
|
-
let outerWidth: number | undefined;
|
|
128
|
-
let fallback = false;
|
|
129
|
-
let top: string | undefined;
|
|
130
|
-
let bottom: string | undefined;
|
|
131
|
-
let renderFrame: BreathingFrame | undefined;
|
|
132
|
-
// Mouse hit-testing follows the last render's boxed or native geometry.
|
|
133
|
-
let boxRendered = false;
|
|
134
|
-
const muted = (text: string) => deps.theme.fg?.('muted', text) ?? text;
|
|
135
|
-
|
|
136
|
-
function nativeRender(receiver: EditorLike, width: number): string[] {
|
|
137
|
-
fallback = true;
|
|
138
|
-
try {
|
|
139
|
-
return originals.render.call(receiver, width);
|
|
140
|
-
} finally {
|
|
141
|
-
fallback = false;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const patches: PropertyDescriptorMap = {
|
|
146
|
-
render: {
|
|
147
|
-
configurable: true,
|
|
148
|
-
writable: true,
|
|
149
|
-
value(this: EditorLike, width: number): string[] {
|
|
150
|
-
boxRendered = false;
|
|
151
|
-
if (!active || width < MIN_INPUT_BOX_WIDTH)
|
|
152
|
-
return nativeRender(this, width);
|
|
153
|
-
const boxWidth = Math.floor(width);
|
|
154
|
-
const innerWidth = boxWidth - 2;
|
|
155
|
-
const maxPadding = Math.max(0, Math.floor((innerWidth - 1) / 2));
|
|
156
|
-
const paddingX = Math.min(originals.getPaddingX.call(this), maxPadding);
|
|
157
|
-
const contentWidth = Math.max(1, innerWidth - paddingX * 2);
|
|
158
|
-
const layoutWidth = Math.max(1, contentWidth - (paddingX ? 0 : 1));
|
|
159
|
-
// Native word wrapping recurses on a wide grapheme at width 1. Keep
|
|
160
|
-
// its original geometry when the box plus current padding is unsafe.
|
|
161
|
-
if (layoutWidth < 2) return nativeRender(this, width);
|
|
162
|
-
// Share one time/color sample with the native status and every border.
|
|
163
|
-
const frame = deps.working.isWorking
|
|
164
|
-
? createBreathingFrame(Date.now())
|
|
165
|
-
: undefined;
|
|
166
|
-
renderFrame = frame;
|
|
167
|
-
outerWidth = boxWidth;
|
|
168
|
-
top = undefined;
|
|
169
|
-
bottom = undefined;
|
|
170
|
-
let lines: string[];
|
|
171
|
-
try {
|
|
172
|
-
lines = originals.render.call(this, outerWidth - 2);
|
|
173
|
-
} finally {
|
|
174
|
-
outerWidth = undefined;
|
|
175
|
-
renderFrame = undefined;
|
|
176
|
-
}
|
|
177
|
-
const bottomIndex =
|
|
178
|
-
bottom === undefined ? -1 : lines.indexOf(bottom, 1);
|
|
179
|
-
if (top === undefined || lines[0] !== top || bottomIndex < 2)
|
|
180
|
-
return nativeRender(this, width);
|
|
181
|
-
const text = originals.getText.call(this);
|
|
182
|
-
lines = lines.map((line, index) => {
|
|
183
|
-
if (index === 0 || index === bottomIndex) return line;
|
|
184
|
-
if (index > bottomIndex)
|
|
185
|
-
return ` ${truncateToWidth(line, width - 2, '', true)} `;
|
|
186
|
-
return wrapContentRow(
|
|
187
|
-
renderPlaceholder(line, width - 2, deps.theme, text),
|
|
188
|
-
width,
|
|
189
|
-
deps.theme,
|
|
190
|
-
frame,
|
|
191
|
-
);
|
|
192
|
-
});
|
|
193
|
-
boxRendered = true;
|
|
194
|
-
return lines;
|
|
195
|
-
},
|
|
196
|
-
},
|
|
197
|
-
renderTopBorder: {
|
|
198
|
-
configurable: true,
|
|
199
|
-
writable: true,
|
|
200
|
-
value(this: EditorLike, width: number, hidden: number): string {
|
|
201
|
-
if (
|
|
202
|
-
outerWidth === undefined ||
|
|
203
|
-
(hidden > 0 &&
|
|
204
|
-
visibleWidth(scrollLabel('up', hidden, deps.iconMode)) >
|
|
205
|
-
outerWidth - 6)
|
|
206
|
-
)
|
|
207
|
-
return originals.renderTopBorder.call(this, width, hidden);
|
|
208
|
-
const status = deps.working.status(
|
|
209
|
-
this.workingStatusIndicator,
|
|
210
|
-
inputLabelWidth(outerWidth, hidden, deps.iconMode),
|
|
211
|
-
muted,
|
|
212
|
-
renderFrame,
|
|
213
|
-
deps.iconMode,
|
|
214
|
-
);
|
|
215
|
-
const data = active ? deps.getStatusSnapshot?.() : undefined;
|
|
216
|
-
const options = { mode: deps.iconMode, theme: deps.theme };
|
|
217
|
-
top = renderInputTop(
|
|
218
|
-
outerWidth,
|
|
219
|
-
deps.theme,
|
|
220
|
-
{
|
|
221
|
-
left: [status, data ? formatBranchSegment(data, options) : ''],
|
|
222
|
-
right: data ? formatCwdSegments(data, options) : [],
|
|
223
|
-
},
|
|
224
|
-
hidden,
|
|
225
|
-
renderFrame,
|
|
226
|
-
deps.iconMode,
|
|
227
|
-
);
|
|
228
|
-
return top;
|
|
229
|
-
},
|
|
230
|
-
},
|
|
231
|
-
renderBottomBorder: {
|
|
232
|
-
configurable: true,
|
|
233
|
-
writable: true,
|
|
234
|
-
value(this: EditorLike, width: number, hidden: number): string {
|
|
235
|
-
if (
|
|
236
|
-
outerWidth === undefined ||
|
|
237
|
-
(hidden > 0 &&
|
|
238
|
-
visibleWidth(scrollLabel('down', hidden, deps.iconMode)) >
|
|
239
|
-
outerWidth - 6)
|
|
240
|
-
)
|
|
241
|
-
return originals.renderBottomBorder.call(this, width, hidden);
|
|
242
|
-
const data = active ? deps.getStatusSnapshot?.() : undefined;
|
|
243
|
-
const options = { mode: deps.iconMode, theme: deps.theme };
|
|
244
|
-
bottom = renderInputBottom(
|
|
245
|
-
outerWidth,
|
|
246
|
-
deps.theme,
|
|
247
|
-
{
|
|
248
|
-
left: data ? formatModelSegments(data, options) : [],
|
|
249
|
-
right: data ? formatContextSegments(data, options) : [],
|
|
250
|
-
},
|
|
251
|
-
hidden,
|
|
252
|
-
renderFrame,
|
|
253
|
-
deps.iconMode,
|
|
254
|
-
);
|
|
255
|
-
return bottom;
|
|
256
|
-
},
|
|
257
|
-
},
|
|
258
|
-
handleMouse: {
|
|
259
|
-
configurable: true,
|
|
260
|
-
writable: true,
|
|
261
|
-
value(
|
|
262
|
-
this: EditorLike,
|
|
263
|
-
event: TuiMouseEvent,
|
|
264
|
-
): TuiMouseEventResult | undefined {
|
|
265
|
-
const translated =
|
|
266
|
-
active && boxRendered
|
|
267
|
-
? { ...event, x: event.x - 1, width: event.width - 2 }
|
|
268
|
-
: event;
|
|
269
|
-
return originals.handleMouse.call(this, translated);
|
|
270
|
-
},
|
|
271
|
-
},
|
|
272
|
-
borderColor: {
|
|
273
|
-
configurable: true,
|
|
274
|
-
get: () => (fallback ? originals.borderColor : muted),
|
|
275
|
-
set: () => {},
|
|
276
|
-
},
|
|
277
|
-
};
|
|
278
|
-
Object.defineProperties(editor, patches);
|
|
279
|
-
const decoration: EditorDecoration = {
|
|
280
|
-
getStatusSnapshot: () => (active ? deps.getStatusSnapshot?.() : undefined),
|
|
281
|
-
dispose() {
|
|
282
|
-
if (!active) return;
|
|
283
|
-
active = false;
|
|
284
|
-
for (const name of names) {
|
|
285
|
-
const current = Reflect.getOwnPropertyDescriptor(editor, name);
|
|
286
|
-
const patch = patches[name];
|
|
287
|
-
if (
|
|
288
|
-
current?.value !== patch.value ||
|
|
289
|
-
current?.get !== patch.get ||
|
|
290
|
-
current?.set !== patch.set
|
|
291
|
-
)
|
|
292
|
-
continue;
|
|
293
|
-
const descriptor = descriptors.get(name);
|
|
294
|
-
if (descriptor) Object.defineProperty(editor, name, descriptor);
|
|
295
|
-
else Reflect.deleteProperty(editor, name);
|
|
296
|
-
}
|
|
297
|
-
decoratedEditors.delete(editor);
|
|
298
|
-
decorations.delete(editor);
|
|
299
|
-
},
|
|
300
|
-
};
|
|
301
|
-
decoratedEditors.add(editor);
|
|
302
|
-
decorations.set(editor, decoration);
|
|
303
|
-
return decoration;
|
|
304
|
-
}
|
package/src/input-box/frame.ts
DELETED
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
|
|
2
|
-
import type { IconMode } from '../shared/config.ts';
|
|
3
|
-
import { icon } from '../shared/icons.ts';
|
|
4
|
-
import type { ActiveThemeLike } from '../status-line/layout.ts';
|
|
5
|
-
import { type BreathingFrame, colorBreathingBorder } from './gradient.ts';
|
|
6
|
-
|
|
7
|
-
function muted(theme: ActiveThemeLike, text: string): string {
|
|
8
|
-
return theme.fg?.('muted', text) ?? text;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function colorBorder(
|
|
12
|
-
theme: ActiveThemeLike,
|
|
13
|
-
text: string,
|
|
14
|
-
frame?: BreathingFrame,
|
|
15
|
-
): string {
|
|
16
|
-
return frame ? colorBreathingBorder(text, frame) : muted(theme, text);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** Editor scroll indicator, `↑/↓ N more` or `^/v N more` by icon mode. */
|
|
20
|
-
export function scrollLabel(
|
|
21
|
-
direction: 'up' | 'down',
|
|
22
|
-
hiddenLineCount: number,
|
|
23
|
-
mode: IconMode = 'nerd',
|
|
24
|
-
): string {
|
|
25
|
-
const arrow = icon(direction === 'up' ? 'scrollUp' : 'scrollDown', mode);
|
|
26
|
-
return `${arrow} ${hiddenLineCount} more`;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function inputLabelWidth(
|
|
30
|
-
width: number,
|
|
31
|
-
hiddenLineCount = 0,
|
|
32
|
-
mode: IconMode = 'nerd',
|
|
33
|
-
): number {
|
|
34
|
-
const overflowWidth =
|
|
35
|
-
hiddenLineCount > 0
|
|
36
|
-
? visibleWidth(scrollLabel('up', hiddenLineCount, mode)) + 3
|
|
37
|
-
: 0;
|
|
38
|
-
return Math.max(0, Math.floor(width) - 6 - overflowWidth);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Left pieces keep priority first; right variants run richest to most compact. */
|
|
42
|
-
export interface BorderRegions {
|
|
43
|
-
left?: readonly string[];
|
|
44
|
-
right?: readonly string[];
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// `╭─ ` + left + ` ─…─ ` + right + ` ─╮`: the rule keeps at least one cell.
|
|
48
|
-
const LEFT_ONLY_OVERHEAD = 6;
|
|
49
|
-
const LEFT_RIGHT_OVERHEAD = 9;
|
|
50
|
-
const RIGHT_ONLY_OVERHEAD = 6;
|
|
51
|
-
|
|
52
|
-
function renderBorder(
|
|
53
|
-
width: number,
|
|
54
|
-
theme: ActiveThemeLike,
|
|
55
|
-
regions: BorderRegions,
|
|
56
|
-
overflow: string,
|
|
57
|
-
capLeft: string,
|
|
58
|
-
capRight: string,
|
|
59
|
-
frame?: BreathingFrame,
|
|
60
|
-
): string {
|
|
61
|
-
width = Math.max(0, Math.floor(width));
|
|
62
|
-
const border = (text: string) => colorBorder(theme, text, frame);
|
|
63
|
-
const pieces = (regions.left ?? []).filter((piece) => visibleWidth(piece));
|
|
64
|
-
const rights = (regions.right ?? []).filter((piece) => visibleWidth(piece));
|
|
65
|
-
const plain = () =>
|
|
66
|
-
truncateToWidth(
|
|
67
|
-
border(`${capLeft}${'─'.repeat(Math.max(0, width - 2))}${capRight}`),
|
|
68
|
-
width,
|
|
69
|
-
'',
|
|
70
|
-
);
|
|
71
|
-
if (width <= 5 || (!pieces.length && !overflow && !rights.length))
|
|
72
|
-
return plain();
|
|
73
|
-
|
|
74
|
-
const withOverflow = (text: string) =>
|
|
75
|
-
!overflow
|
|
76
|
-
? text
|
|
77
|
-
: !text
|
|
78
|
-
? muted(theme, overflow)
|
|
79
|
-
: frame
|
|
80
|
-
? `${text}${border(' ─ ')}${muted(theme, overflow)}`
|
|
81
|
-
: `${text}${muted(theme, ` ─ ${overflow}`)}`;
|
|
82
|
-
const leftWith = (count: number) =>
|
|
83
|
-
withOverflow(pieces.slice(0, count).join(' '));
|
|
84
|
-
|
|
85
|
-
// Degrade right first (compact, then drop), then left secondaries.
|
|
86
|
-
const full = leftWith(pieces.length);
|
|
87
|
-
const fullWidth = visibleWidth(full);
|
|
88
|
-
for (const right of rights) {
|
|
89
|
-
const rightWidth = visibleWidth(right);
|
|
90
|
-
if (!full) {
|
|
91
|
-
if (rightWidth > width - RIGHT_ONLY_OVERHEAD) continue;
|
|
92
|
-
const dashes = width - 5 - rightWidth;
|
|
93
|
-
return `${border(`${capLeft}${'─'.repeat(dashes)} `)}${right}${border(` ─${capRight}`)}`;
|
|
94
|
-
}
|
|
95
|
-
if (fullWidth + rightWidth > width - LEFT_RIGHT_OVERHEAD) continue;
|
|
96
|
-
const dashes = width - 8 - fullWidth - rightWidth;
|
|
97
|
-
return `${border(`${capLeft}─ `)}${full}${border(` ${'─'.repeat(dashes)} `)}${right}${border(` ─${capRight}`)}`;
|
|
98
|
-
}
|
|
99
|
-
if (!full) return plain();
|
|
100
|
-
|
|
101
|
-
const labelWidth = width - LEFT_ONLY_OVERHEAD;
|
|
102
|
-
let label: string | undefined;
|
|
103
|
-
for (let count = pieces.length; count >= 1; count--) {
|
|
104
|
-
const candidate = leftWith(count);
|
|
105
|
-
if (visibleWidth(candidate) <= labelWidth) {
|
|
106
|
-
label = candidate;
|
|
107
|
-
break;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
if (label === undefined) {
|
|
111
|
-
if (overflow) {
|
|
112
|
-
const statusWidth = labelWidth - visibleWidth(overflow) - 3;
|
|
113
|
-
const status =
|
|
114
|
-
statusWidth > 0 && pieces.length
|
|
115
|
-
? truncateToWidth(pieces[0], statusWidth, '')
|
|
116
|
-
: '';
|
|
117
|
-
label = withOverflow(visibleWidth(status) ? status : '');
|
|
118
|
-
} else {
|
|
119
|
-
label = truncateToWidth(pieces[0], labelWidth, '');
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
const fitted = truncateToWidth(label, labelWidth, '');
|
|
123
|
-
const dashes = width - visibleWidth(fitted) - 5;
|
|
124
|
-
return `${border(`${capLeft}─ `)}${fitted}${border(` ${'─'.repeat(dashes)}${capRight}`)}`;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
export function renderInputTop(
|
|
128
|
-
width: number,
|
|
129
|
-
theme: ActiveThemeLike,
|
|
130
|
-
regions: BorderRegions,
|
|
131
|
-
hiddenLineCount = 0,
|
|
132
|
-
frame?: BreathingFrame,
|
|
133
|
-
mode: IconMode = 'nerd',
|
|
134
|
-
): string {
|
|
135
|
-
const overflow =
|
|
136
|
-
hiddenLineCount > 0 ? scrollLabel('up', hiddenLineCount, mode) : '';
|
|
137
|
-
return renderBorder(width, theme, regions, overflow, '╭', '╮', frame);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export function renderInputBottom(
|
|
141
|
-
width: number,
|
|
142
|
-
theme: ActiveThemeLike,
|
|
143
|
-
regions: BorderRegions,
|
|
144
|
-
hiddenLineCount = 0,
|
|
145
|
-
frame?: BreathingFrame,
|
|
146
|
-
mode: IconMode = 'nerd',
|
|
147
|
-
): string {
|
|
148
|
-
const overflow =
|
|
149
|
-
hiddenLineCount > 0 ? scrollLabel('down', hiddenLineCount, mode) : '';
|
|
150
|
-
return renderBorder(width, theme, regions, overflow, '╰', '╯', frame);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
export function wrapContentRow(
|
|
154
|
-
line: string,
|
|
155
|
-
width: number,
|
|
156
|
-
theme: ActiveThemeLike,
|
|
157
|
-
frame?: BreathingFrame,
|
|
158
|
-
): string {
|
|
159
|
-
width = Math.max(0, Math.floor(width));
|
|
160
|
-
if (width < 2)
|
|
161
|
-
return truncateToWidth(colorBorder(theme, '││', frame), width, '');
|
|
162
|
-
const content = truncateToWidth(line, width - 2, '', true);
|
|
163
|
-
const side = colorBorder(theme, '│', frame);
|
|
164
|
-
return `${side}${content}${side}`;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
export function renderPlaceholder(
|
|
168
|
-
line: string,
|
|
169
|
-
width: number,
|
|
170
|
-
theme: ActiveThemeLike,
|
|
171
|
-
text: string,
|
|
172
|
-
): string {
|
|
173
|
-
if (text !== '') return line;
|
|
174
|
-
const cursor = '\x1b[7m \x1b[0m';
|
|
175
|
-
const cursorStart = line.indexOf(cursor);
|
|
176
|
-
if (cursorStart < 0) return line;
|
|
177
|
-
const prefix = line.slice(0, cursorStart + cursor.length);
|
|
178
|
-
const available = Math.max(0, width - visibleWidth(prefix));
|
|
179
|
-
const placeholder = truncateToWidth('type or / for commands', available, '');
|
|
180
|
-
const padding = ' '.repeat(available - visibleWidth(placeholder));
|
|
181
|
-
return prefix + (theme.fg?.('dim', placeholder) ?? placeholder) + padding;
|
|
182
|
-
}
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
foregroundAnsi,
|
|
3
|
-
getTerminalColorMode,
|
|
4
|
-
mixColors,
|
|
5
|
-
parseColor,
|
|
6
|
-
stripTerminalSequences,
|
|
7
|
-
styleText,
|
|
8
|
-
type TerminalColorMode,
|
|
9
|
-
} from '@earendil-works/pi-tui';
|
|
10
|
-
import { frames } from '../shared/icons.ts';
|
|
11
|
-
|
|
12
|
-
const GOLD = parseColor('#D4AF37');
|
|
13
|
-
const BRIGHT_GOLD = parseColor('#F2C94C');
|
|
14
|
-
const DIM = parseColor('#736850');
|
|
15
|
-
const MUTED = parseColor('#A89A78');
|
|
16
|
-
const BREATHING_CYCLE_MS = 2400;
|
|
17
|
-
const SHIMMER_LAP_MS = 2400;
|
|
18
|
-
const SHIMMER_BAND_CELLS = 3;
|
|
19
|
-
|
|
20
|
-
export interface BreathingFrame {
|
|
21
|
-
now: number;
|
|
22
|
-
foreground: string;
|
|
23
|
-
mode: TerminalColorMode;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function createBreathingFrame(
|
|
27
|
-
now: number,
|
|
28
|
-
mode = getTerminalColorMode(),
|
|
29
|
-
): BreathingFrame {
|
|
30
|
-
const phase = ((now % BREATHING_CYCLE_MS) / BREATHING_CYCLE_MS) * 2 * Math.PI;
|
|
31
|
-
const intensity = (1 - Math.cos(phase)) / 2;
|
|
32
|
-
const color =
|
|
33
|
-
intensity <= 0.5
|
|
34
|
-
? mixColors(DIM, GOLD, intensity * 2, 'srgb')
|
|
35
|
-
: mixColors(GOLD, BRIGHT_GOLD, (intensity - 0.5) * 2, 'srgb');
|
|
36
|
-
return { now, foreground: foregroundAnsi(color, mode), mode };
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// Only call with generated border segments, never label or editor content.
|
|
40
|
-
export function colorBreathingBorder(
|
|
41
|
-
text: string,
|
|
42
|
-
frame: BreathingFrame,
|
|
43
|
-
): string {
|
|
44
|
-
return `${frame.foreground}${text}\x1b[39m`;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export function renderWorkingIndicator(
|
|
48
|
-
native: string,
|
|
49
|
-
frame: BreathingFrame,
|
|
50
|
-
styleMuted: (text: string) => string,
|
|
51
|
-
glyphs: readonly string[] = frames('workingFrames', 'nerd'),
|
|
52
|
-
): string {
|
|
53
|
-
const plain = stripTerminalSequences(native);
|
|
54
|
-
const glyph = glyphs.find(
|
|
55
|
-
(candidate) =>
|
|
56
|
-
plain.startsWith(candidate) &&
|
|
57
|
-
(plain.length === candidate.length ||
|
|
58
|
-
/^\s/u.test(plain.slice(candidate.length))),
|
|
59
|
-
);
|
|
60
|
-
const spacing =
|
|
61
|
-
glyph === undefined
|
|
62
|
-
? ''
|
|
63
|
-
: (/^\s*/u.exec(plain.slice(glyph.length))?.[0] ?? '');
|
|
64
|
-
const prefix =
|
|
65
|
-
glyph === undefined ? '' : `${frame.foreground}${glyph}[39m${spacing}`;
|
|
66
|
-
const letters = Array.from(
|
|
67
|
-
plain.slice(glyph === undefined ? 0 : glyph.length + spacing.length),
|
|
68
|
-
);
|
|
69
|
-
const head =
|
|
70
|
-
((frame.now % SHIMMER_LAP_MS) / SHIMMER_LAP_MS) *
|
|
71
|
-
(letters.length + SHIMMER_BAND_CELLS * 2) -
|
|
72
|
-
SHIMMER_BAND_CELLS;
|
|
73
|
-
const label = letters
|
|
74
|
-
.map((letter, index) => {
|
|
75
|
-
const intensity = Math.max(
|
|
76
|
-
0,
|
|
77
|
-
1 - Math.abs(index - head) / SHIMMER_BAND_CELLS,
|
|
78
|
-
);
|
|
79
|
-
return intensity === 0
|
|
80
|
-
? styleMuted(letter)
|
|
81
|
-
: styleText(
|
|
82
|
-
letter,
|
|
83
|
-
{
|
|
84
|
-
fg: mixColors(MUTED, BRIGHT_GOLD, intensity, 'srgb'),
|
|
85
|
-
bold: intensity >= 0.65,
|
|
86
|
-
},
|
|
87
|
-
frame.mode,
|
|
88
|
-
);
|
|
89
|
-
})
|
|
90
|
-
.join('');
|
|
91
|
-
return prefix + label;
|
|
92
|
-
}
|