@agnishc/edb-append-system-prompt 0.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/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +32 -0
- package/package.json +26 -0
- package/src/component.ts +194 -0
- package/src/index.ts +88 -0
- package/src/state.ts +52 -0
- package/src/types.ts +9 -0
- package/src/utils.ts +13 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Initial release: `/sys-prompt` command with compose and list overlay modes
|
|
7
|
+
- Confirm dialog before adding snippets
|
|
8
|
+
- Status bar indicator showing active snippet count
|
|
9
|
+
- Session persistence via session storage entries
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Agnish Chakraborty
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# @agnishc/edb-append-system-prompt
|
|
2
|
+
|
|
3
|
+
A Pi CLI extension that lets you build up a list of system-prompt snippets that are appended to every agent turn in the current session.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Always appends** — snippets accumulate as a list, never replace the base prompt
|
|
8
|
+
- **Confirm before adding** — shows the exact text in a confirm dialog before saving
|
|
9
|
+
- **Status bar indicator** — `⊕ N snippets` shown when active snippets exist
|
|
10
|
+
- **Delete individual snippets** — select and delete from the list view
|
|
11
|
+
- **Persists across `/reload`** — stored in session history, survives extension reloads
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pi install npm:@agnishc/edb-append-system-prompt
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
/sys-prompt
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Opens an overlay with two modes:
|
|
26
|
+
|
|
27
|
+
- **Compose mode** — write a snippet, press Enter to add (with confirm dialog)
|
|
28
|
+
- **List mode** — browse existing snippets, press `d` to delete selected
|
|
29
|
+
|
|
30
|
+
## License
|
|
31
|
+
|
|
32
|
+
[MIT](LICENSE) © Agnish Chakraborty
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agnishc/edb-append-system-prompt",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension: manage per-session system prompt snippets with add/delete UI",
|
|
5
|
+
"keywords": ["pi-package", "pi-extension", "edb"],
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "Agnish Chakraborty",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/agnishcc/pi-extention-monorepo.git",
|
|
12
|
+
"directory": "packages/edb-append-system-prompt"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/agnishcc/pi-extention-monorepo/tree/main/packages/edb-append-system-prompt#readme",
|
|
15
|
+
"bugs": { "url": "https://github.com/agnishcc/pi-extention-monorepo/issues" },
|
|
16
|
+
"publishConfig": { "access": "public" },
|
|
17
|
+
"scripts": { "test": "vitest run" },
|
|
18
|
+
"files": ["src", "README.md", "LICENSE", "CHANGELOG.md"],
|
|
19
|
+
"pi": {
|
|
20
|
+
"extensions": ["./src/index.ts"]
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
24
|
+
"@mariozechner/pi-tui": "*"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/component.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Editor,
|
|
3
|
+
type EditorTheme,
|
|
4
|
+
Key,
|
|
5
|
+
matchesKey,
|
|
6
|
+
type SelectItem,
|
|
7
|
+
SelectList,
|
|
8
|
+
truncateToWidth,
|
|
9
|
+
visibleWidth,
|
|
10
|
+
} from "@mariozechner/pi-tui";
|
|
11
|
+
import { snippets } from "./state";
|
|
12
|
+
import type { OverlayAction } from "./types";
|
|
13
|
+
import { formatAge, wordCount } from "./utils";
|
|
14
|
+
|
|
15
|
+
// ── Overlay launcher ───────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export function openOverlay(ctx: any, prefillText?: string): Promise<OverlayAction | undefined> {
|
|
18
|
+
return (ctx.ui as any).custom(
|
|
19
|
+
(tui: any, theme: any, _kb: any, done: (result?: OverlayAction) => void) =>
|
|
20
|
+
createComponent(tui, theme, done, prefillText),
|
|
21
|
+
{
|
|
22
|
+
overlay: true,
|
|
23
|
+
overlayOptions: {
|
|
24
|
+
anchor: "center" as const,
|
|
25
|
+
width: "65%" as const,
|
|
26
|
+
maxHeight: "80%" as const,
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Component ──────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
type Mode = "list" | "composing";
|
|
35
|
+
|
|
36
|
+
export function createComponent(tui: any, theme: any, done: (result?: OverlayAction) => void, prefillText?: string) {
|
|
37
|
+
const dim = (s: string) => theme.fg("dim", s);
|
|
38
|
+
const accent = (s: string) => theme.fg("accent", s);
|
|
39
|
+
const muted = (s: string) => theme.fg("muted", s);
|
|
40
|
+
|
|
41
|
+
let mode: Mode = snippets.length === 0 ? "composing" : "list";
|
|
42
|
+
|
|
43
|
+
// ── Editor (composing mode) ────────────────────────────────────────────
|
|
44
|
+
const editorTheme: EditorTheme = {
|
|
45
|
+
borderColor: (s) => theme.fg("accent", s),
|
|
46
|
+
selectList: {
|
|
47
|
+
selectedPrefix: (t) => theme.fg("accent", t),
|
|
48
|
+
selectedText: (t) => theme.fg("accent", t),
|
|
49
|
+
description: (t) => theme.fg("muted", t),
|
|
50
|
+
scrollInfo: (t) => theme.fg("dim", t),
|
|
51
|
+
noMatch: (t) => theme.fg("warning", t),
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
const editor = new Editor(tui, editorTheme);
|
|
55
|
+
editor.focused = true;
|
|
56
|
+
if (prefillText) editor.setText(prefillText);
|
|
57
|
+
|
|
58
|
+
editor.onSubmit = (text) => {
|
|
59
|
+
const trimmed = text.trim();
|
|
60
|
+
if (trimmed) {
|
|
61
|
+
done({ type: "add", text: trimmed });
|
|
62
|
+
} else if (snippets.length > 0) {
|
|
63
|
+
mode = "list";
|
|
64
|
+
tui.requestRender();
|
|
65
|
+
} else {
|
|
66
|
+
done();
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// ── SelectList (list mode) ─────────────────────────────────────────────
|
|
71
|
+
const selectTheme = {
|
|
72
|
+
selectedPrefix: (t: string) => theme.fg("accent", t),
|
|
73
|
+
selectedText: (t: string) => theme.fg("accent", t),
|
|
74
|
+
description: (t: string) => theme.fg("dim", t),
|
|
75
|
+
scrollInfo: (t: string) => theme.fg("dim", t),
|
|
76
|
+
noMatch: (t: string) => theme.fg("warning", t),
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
function buildItems(): SelectItem[] {
|
|
80
|
+
const items: SelectItem[] = [{ value: "__add__", label: accent("+ Add new snippet"), description: "" }];
|
|
81
|
+
for (const s of snippets) {
|
|
82
|
+
const preview = s.text.replace(/\n/g, " ");
|
|
83
|
+
items.push({
|
|
84
|
+
value: s.id,
|
|
85
|
+
label: truncateToWidth(preview, 55),
|
|
86
|
+
description: `${formatAge(s.createdAt)} · ${wordCount(s.text)} words`,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return items;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const list = new SelectList(buildItems(), 12, selectTheme);
|
|
93
|
+
|
|
94
|
+
// ── Rendering ──────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
function renderHeader(width: number): string[] {
|
|
97
|
+
const title = theme.bold(accent(" ✦ System Prompt Snippets"));
|
|
98
|
+
const count = snippets.length === 0 ? muted("none active") : accent(`${snippets.length} active`);
|
|
99
|
+
const gap = Math.max(2, width - visibleWidth(" ✦ System Prompt Snippets") - visibleWidth(count) - 1);
|
|
100
|
+
return [title + " ".repeat(gap) + count];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function renderBody(width: number): string[] {
|
|
104
|
+
if (mode === "composing") {
|
|
105
|
+
const lines: string[] = [];
|
|
106
|
+
lines.push(dim(" Write your system prompt addition:"));
|
|
107
|
+
lines.push("");
|
|
108
|
+
for (const line of editor.render(width - 2)) {
|
|
109
|
+
lines.push(` ${line}`);
|
|
110
|
+
}
|
|
111
|
+
return lines;
|
|
112
|
+
}
|
|
113
|
+
return list.render(width);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function renderFooter(width: number): string[] {
|
|
117
|
+
const divider = dim("─".repeat(width));
|
|
118
|
+
if (mode === "composing") {
|
|
119
|
+
return [divider, dim(` Enter submit · Esc ${snippets.length > 0 ? "back to list" : "close"}`)];
|
|
120
|
+
}
|
|
121
|
+
return [divider, dim(" ↑↓ navigate · Enter select · d delete selected · Esc close")];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Input handling ─────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
render(width: number): string[] {
|
|
128
|
+
return [...renderHeader(width), dim("─".repeat(width)), ...renderBody(width), ...renderFooter(width)];
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
handleInput(data: string): void {
|
|
132
|
+
if (mode === "composing") {
|
|
133
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
134
|
+
if (snippets.length > 0) {
|
|
135
|
+
mode = "list";
|
|
136
|
+
tui.requestRender();
|
|
137
|
+
} else {
|
|
138
|
+
done();
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
editor.handleInput(data);
|
|
143
|
+
tui.requestRender();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// list mode
|
|
148
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
149
|
+
done();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (data === "d") {
|
|
154
|
+
const sel = list.getSelectedItem();
|
|
155
|
+
if (sel && sel.value !== "__add__") {
|
|
156
|
+
const snippet = snippets.find((s) => s.id === sel.value);
|
|
157
|
+
if (snippet) done({ type: "delete", id: snippet.id, text: snippet.text });
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (matchesKey(data, Key.enter) || matchesKey(data, Key.return)) {
|
|
163
|
+
const sel = list.getSelectedItem();
|
|
164
|
+
if (!sel) return;
|
|
165
|
+
if (sel.value === "__add__") {
|
|
166
|
+
mode = "composing";
|
|
167
|
+
editor.setText("");
|
|
168
|
+
editor.focused = true;
|
|
169
|
+
tui.requestRender();
|
|
170
|
+
} else {
|
|
171
|
+
const snippet = snippets.find((s) => s.id === sel.value);
|
|
172
|
+
if (snippet) done({ type: "delete", id: snippet.id, text: snippet.text });
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (
|
|
178
|
+
matchesKey(data, Key.up) ||
|
|
179
|
+
matchesKey(data, Key.down) ||
|
|
180
|
+
matchesKey(data, Key.pageUp) ||
|
|
181
|
+
matchesKey(data, Key.pageDown)
|
|
182
|
+
) {
|
|
183
|
+
list.handleInput(data);
|
|
184
|
+
tui.requestRender();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
invalidate(): void {
|
|
190
|
+
editor.invalidate?.();
|
|
191
|
+
list.invalidate();
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-append-system-prompt
|
|
3
|
+
*
|
|
4
|
+
* Manages a list of system-prompt snippets that are appended to every agent
|
|
5
|
+
* turn in the current session.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Always appends (never replaces) — snippets accumulate as a list
|
|
9
|
+
* - Confirm dialog before any snippet is added, showing the exact text
|
|
10
|
+
* - Status bar indicator when snippets are active
|
|
11
|
+
* - Delete individual snippets
|
|
12
|
+
* - Persists across /reload via session storage — scoped to this session only
|
|
13
|
+
*
|
|
14
|
+
* Command: /sys-prompt
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
|
18
|
+
import { openOverlay } from "./component";
|
|
19
|
+
import {
|
|
20
|
+
addSnippet,
|
|
21
|
+
loadFromSession,
|
|
22
|
+
persistSnippets,
|
|
23
|
+
removeSnippet,
|
|
24
|
+
setSnippets,
|
|
25
|
+
snippets,
|
|
26
|
+
updateStatusBar,
|
|
27
|
+
} from "./state";
|
|
28
|
+
|
|
29
|
+
// ── Extension ──────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
export default function sysPromptExtension(pi: ExtensionAPI): void {
|
|
32
|
+
// Restore state on session start / reload
|
|
33
|
+
pi.on("session_start", async (_e, ctx) => {
|
|
34
|
+
setSnippets(loadFromSession(ctx));
|
|
35
|
+
updateStatusBar(ctx);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Append all snippets to the system prompt before each turn
|
|
39
|
+
pi.on("before_agent_start", async (event) => {
|
|
40
|
+
if (snippets.length === 0) return;
|
|
41
|
+
const addition = snippets.map((s) => s.text).join("\n\n");
|
|
42
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${addition}` };
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
pi.registerCommand("sys-prompt", {
|
|
46
|
+
description: "Manage system prompt snippets — add, view, and delete",
|
|
47
|
+
handler: async (_args, ctx: ExtensionCommandContext) => {
|
|
48
|
+
if (!ctx.hasUI) return;
|
|
49
|
+
await ctx.waitForIdle();
|
|
50
|
+
|
|
51
|
+
let pendingText: string | undefined;
|
|
52
|
+
let shouldReopen = false;
|
|
53
|
+
|
|
54
|
+
do {
|
|
55
|
+
shouldReopen = false;
|
|
56
|
+
const action = await openOverlay(ctx, pendingText);
|
|
57
|
+
pendingText = undefined;
|
|
58
|
+
|
|
59
|
+
if (!action) break;
|
|
60
|
+
|
|
61
|
+
if (action.type === "add") {
|
|
62
|
+
const preview = action.text.length > 500 ? `${action.text.slice(0, 497)}…` : action.text;
|
|
63
|
+
const confirmed = await ctx.ui.confirm("Add this to your system prompt?", preview);
|
|
64
|
+
if (confirmed) {
|
|
65
|
+
addSnippet(action.text);
|
|
66
|
+
persistSnippets(pi);
|
|
67
|
+
updateStatusBar(ctx);
|
|
68
|
+
} else {
|
|
69
|
+
// Preserve typed text so user doesn't lose their work
|
|
70
|
+
pendingText = action.text;
|
|
71
|
+
}
|
|
72
|
+
shouldReopen = true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (action.type === "delete") {
|
|
76
|
+
const preview = action.text.length > 300 ? `${action.text.slice(0, 297)}…` : action.text;
|
|
77
|
+
const confirmed = await ctx.ui.confirm("Remove this snippet from your system prompt?", preview);
|
|
78
|
+
if (confirmed) {
|
|
79
|
+
removeSnippet(action.id);
|
|
80
|
+
persistSnippets(pi);
|
|
81
|
+
updateStatusBar(ctx);
|
|
82
|
+
}
|
|
83
|
+
shouldReopen = true;
|
|
84
|
+
}
|
|
85
|
+
} while (shouldReopen);
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import type { Snippet } from "./types";
|
|
4
|
+
|
|
5
|
+
// ── Module state ───────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export let snippets: Snippet[] = [];
|
|
8
|
+
|
|
9
|
+
export const STATUS_KEY = "sys-prompt";
|
|
10
|
+
export const ENTRY_TYPE = "sys-prompt-snippets";
|
|
11
|
+
|
|
12
|
+
// ── State helpers ──────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export function addSnippet(text: string): void {
|
|
15
|
+
snippets.push({ id: randomUUID(), text: text.trim(), createdAt: Date.now() });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function removeSnippet(id: string): void {
|
|
19
|
+
snippets = snippets.filter((s) => s.id !== id);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function setSnippets(next: Snippet[]): void {
|
|
23
|
+
snippets = next;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── Session persistence ────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
export function loadFromSession(ctx: any): Snippet[] {
|
|
29
|
+
const entries: any[] = ctx.sessionManager.getEntries();
|
|
30
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
31
|
+
const e = entries[i];
|
|
32
|
+
if (e.type === "custom" && e.customType === ENTRY_TYPE) {
|
|
33
|
+
return (e.data?.snippets as Snippet[]) ?? [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function persistSnippets(pi: ExtensionAPI): void {
|
|
40
|
+
pi.appendEntry(ENTRY_TYPE, { snippets });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── Status bar ─────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
export function updateStatusBar(ctx: any): void {
|
|
46
|
+
if (snippets.length === 0) {
|
|
47
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const label = snippets.length === 1 ? "1 snippet" : `${snippets.length} snippets`;
|
|
51
|
+
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("accent", `⊕ ${label}`));
|
|
52
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// ── Types ──────────────────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export interface Snippet {
|
|
4
|
+
id: string;
|
|
5
|
+
text: string;
|
|
6
|
+
createdAt: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type OverlayAction = { type: "add"; text: string } | { type: "delete"; id: string; text: string };
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// ── Utils ──────────────────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export function formatAge(ts: number): string {
|
|
4
|
+
const s = Math.floor((Date.now() - ts) / 1000);
|
|
5
|
+
if (s < 60) return `${s}s ago`;
|
|
6
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
7
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
|
8
|
+
return `${Math.floor(s / 86400)}d ago`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function wordCount(text: string): number {
|
|
12
|
+
return text.trim().split(/\s+/).filter(Boolean).length;
|
|
13
|
+
}
|