@teammates/cli 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 +31 -22
- package/dist/adapter.d.ts +1 -1
- package/dist/adapter.js +68 -56
- package/dist/adapter.test.js +34 -21
- package/dist/adapters/cli-proxy.d.ts +11 -4
- package/dist/adapters/cli-proxy.js +176 -162
- package/dist/adapters/copilot.d.ts +50 -0
- package/dist/adapters/copilot.js +210 -0
- package/dist/adapters/echo.d.ts +2 -2
- package/dist/adapters/echo.js +2 -1
- package/dist/adapters/echo.test.js +4 -2
- package/dist/cli-utils.d.ts +21 -0
- package/dist/cli-utils.js +74 -0
- package/dist/cli-utils.test.d.ts +1 -0
- package/dist/cli-utils.test.js +179 -0
- package/dist/cli.js +3160 -961
- package/dist/compact.d.ts +39 -0
- package/dist/compact.js +269 -0
- package/dist/compact.test.d.ts +1 -0
- package/dist/compact.test.js +198 -0
- package/dist/console/ansi.d.ts +18 -0
- package/dist/console/ansi.js +20 -0
- package/dist/console/ansi.test.d.ts +1 -0
- package/dist/console/ansi.test.js +50 -0
- package/dist/console/dropdown.d.ts +23 -0
- package/dist/console/dropdown.js +63 -0
- package/dist/console/file-drop.d.ts +59 -0
- package/dist/console/file-drop.js +186 -0
- package/dist/console/file-drop.test.d.ts +1 -0
- package/dist/console/file-drop.test.js +145 -0
- package/dist/console/index.d.ts +22 -0
- package/dist/console/index.js +23 -0
- package/dist/console/interactive-readline.d.ts +65 -0
- package/dist/console/interactive-readline.js +132 -0
- package/dist/console/markdown-table.d.ts +17 -0
- package/dist/console/markdown-table.js +270 -0
- package/dist/console/markdown-table.test.d.ts +1 -0
- package/dist/console/markdown-table.test.js +130 -0
- package/dist/console/mutable-output.d.ts +21 -0
- package/dist/console/mutable-output.js +51 -0
- package/dist/console/paste-handler.d.ts +63 -0
- package/dist/console/paste-handler.js +177 -0
- package/dist/console/prompt-box.d.ts +55 -0
- package/dist/console/prompt-box.js +120 -0
- package/dist/console/prompt-input.d.ts +136 -0
- package/dist/console/prompt-input.js +618 -0
- package/dist/console/startup.d.ts +20 -0
- package/dist/console/startup.js +138 -0
- package/dist/console/startup.test.d.ts +1 -0
- package/dist/console/startup.test.js +41 -0
- package/dist/console/wordwheel.d.ts +75 -0
- package/dist/console/wordwheel.js +123 -0
- package/dist/dropdown.js +4 -21
- package/dist/index.d.ts +5 -5
- package/dist/index.js +3 -3
- package/dist/onboard.d.ts +24 -0
- package/dist/onboard.js +174 -11
- package/dist/orchestrator.d.ts +8 -11
- package/dist/orchestrator.js +33 -81
- package/dist/orchestrator.test.js +59 -79
- package/dist/registry.d.ts +1 -1
- package/dist/registry.js +56 -12
- package/dist/registry.test.js +57 -13
- package/dist/theme.d.ts +56 -0
- package/dist/theme.js +54 -0
- package/dist/types.d.ts +18 -13
- package/package.json +8 -3
- package/template/CROSS-TEAM.md +2 -2
- package/template/PROTOCOL.md +72 -15
- package/template/README.md +2 -2
- package/template/TEMPLATE.md +118 -15
- package/template/example/SOUL.md +2 -1
- package/template/example/WISDOM.md +9 -0
- package/dist/adapters/codex.d.ts +0 -50
- package/dist/adapters/codex.js +0 -213
- package/template/example/MEMORIES.md +0 -26
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Copilot SDK adapter — uses the @github/copilot-sdk to run tasks
|
|
3
|
+
* through GitHub Copilot's agentic coding engine.
|
|
4
|
+
*
|
|
5
|
+
* Unlike the CLI proxy adapter (which spawns subprocesses), this adapter
|
|
6
|
+
* communicates with Copilot via the SDK's JSON-RPC protocol, giving us:
|
|
7
|
+
* - Structured event streaming (no stdout scraping)
|
|
8
|
+
* - Session persistence via Copilot's infinite sessions
|
|
9
|
+
* - Direct tool/permission control
|
|
10
|
+
* - Access to Copilot's built-in coding tools (file ops, git, bash, etc.)
|
|
11
|
+
*/
|
|
12
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { approveAll, CopilotClient, } from "@github/copilot-sdk";
|
|
15
|
+
import { buildTeammatePrompt } from "../adapter.js";
|
|
16
|
+
import { parseResult } from "./cli-proxy.js";
|
|
17
|
+
// ─── Adapter ─────────────────────────────────────────────────────────
|
|
18
|
+
let nextId = 1;
|
|
19
|
+
export class CopilotAdapter {
|
|
20
|
+
name = "copilot";
|
|
21
|
+
/** Team roster — set by the orchestrator so prompts include teammate info. */
|
|
22
|
+
roster = [];
|
|
23
|
+
/** Installed services — set by the CLI so prompts include service info. */
|
|
24
|
+
services = [];
|
|
25
|
+
options;
|
|
26
|
+
client = null;
|
|
27
|
+
sessions = new Map();
|
|
28
|
+
/** Session files per teammate — persists state across task invocations. */
|
|
29
|
+
sessionFiles = new Map();
|
|
30
|
+
/** Base directory for session files. */
|
|
31
|
+
sessionsDir = "";
|
|
32
|
+
constructor(options = {}) {
|
|
33
|
+
this.options = options;
|
|
34
|
+
}
|
|
35
|
+
async startSession(teammate) {
|
|
36
|
+
const id = `copilot-${teammate.name}-${nextId++}`;
|
|
37
|
+
// Ensure the client is running
|
|
38
|
+
await this.ensureClient(teammate.cwd);
|
|
39
|
+
// Create session file inside .teammates/.tmp so the agent can access it
|
|
40
|
+
if (!this.sessionsDir) {
|
|
41
|
+
const tmpBase = join(teammate.cwd ?? process.cwd(), ".teammates", ".tmp");
|
|
42
|
+
this.sessionsDir = join(tmpBase, "sessions");
|
|
43
|
+
await mkdir(this.sessionsDir, { recursive: true });
|
|
44
|
+
const gitignorePath = join(tmpBase, "..", ".gitignore");
|
|
45
|
+
const existing = await readFile(gitignorePath, "utf-8").catch(() => "");
|
|
46
|
+
if (!existing.includes(".tmp/")) {
|
|
47
|
+
await writeFile(gitignorePath, existing +
|
|
48
|
+
(existing.endsWith("\n") || !existing ? "" : "\n") +
|
|
49
|
+
".tmp/\n").catch(() => { });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const sessionFile = join(this.sessionsDir, `${teammate.name}.md`);
|
|
53
|
+
await writeFile(sessionFile, `# Session — ${teammate.name}\n\n`, "utf-8");
|
|
54
|
+
this.sessionFiles.set(teammate.name, sessionFile);
|
|
55
|
+
return id;
|
|
56
|
+
}
|
|
57
|
+
async executeTask(_sessionId, teammate, prompt) {
|
|
58
|
+
await this.ensureClient(teammate.cwd);
|
|
59
|
+
const sessionFile = this.sessionFiles.get(teammate.name);
|
|
60
|
+
// Build the full teammate prompt (identity + memory + task)
|
|
61
|
+
let fullPrompt;
|
|
62
|
+
if (teammate.soul) {
|
|
63
|
+
fullPrompt = buildTeammatePrompt(teammate, prompt, {
|
|
64
|
+
roster: this.roster,
|
|
65
|
+
services: this.services,
|
|
66
|
+
sessionFile,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
// Raw agent mode — minimal wrapping
|
|
71
|
+
const parts = [prompt];
|
|
72
|
+
const others = this.roster.filter((r) => r.name !== teammate.name);
|
|
73
|
+
if (others.length > 0) {
|
|
74
|
+
parts.push("\n\n---\n");
|
|
75
|
+
parts.push("If part of this task belongs to a specialist, you can hand it off.");
|
|
76
|
+
parts.push("Your teammates:");
|
|
77
|
+
for (const t of others) {
|
|
78
|
+
const owns = t.ownership.primary.length > 0
|
|
79
|
+
? ` — owns: ${t.ownership.primary.join(", ")}`
|
|
80
|
+
: "";
|
|
81
|
+
parts.push(`- @${t.name}: ${t.role}${owns}`);
|
|
82
|
+
}
|
|
83
|
+
parts.push("\nTo hand off, include a fenced handoff block in your response:");
|
|
84
|
+
parts.push("```handoff\n@<teammate>\n<task details>\n```");
|
|
85
|
+
}
|
|
86
|
+
fullPrompt = parts.join("\n");
|
|
87
|
+
}
|
|
88
|
+
// Create a Copilot session with the teammate prompt as the system message
|
|
89
|
+
const session = await this.client.createSession({
|
|
90
|
+
model: this.options.model,
|
|
91
|
+
systemMessage: {
|
|
92
|
+
mode: "replace",
|
|
93
|
+
content: fullPrompt,
|
|
94
|
+
},
|
|
95
|
+
onPermissionRequest: approveAll,
|
|
96
|
+
provider: this.options.provider
|
|
97
|
+
? {
|
|
98
|
+
type: this.options.provider.type ?? "openai",
|
|
99
|
+
baseUrl: this.options.provider.baseUrl,
|
|
100
|
+
apiKey: this.options.provider.apiKey,
|
|
101
|
+
}
|
|
102
|
+
: undefined,
|
|
103
|
+
workingDirectory: teammate.cwd ?? process.cwd(),
|
|
104
|
+
});
|
|
105
|
+
// Collect the assistant's response silently — the CLI handles rendering.
|
|
106
|
+
// We do NOT write to stdout here; that would corrupt the consolonia UI.
|
|
107
|
+
const outputParts = [];
|
|
108
|
+
session.on("assistant.message_delta", (event) => {
|
|
109
|
+
const delta = event.data
|
|
110
|
+
?.deltaContent;
|
|
111
|
+
if (delta) {
|
|
112
|
+
outputParts.push(delta);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
try {
|
|
116
|
+
const timeout = this.options.timeout ?? 600_000;
|
|
117
|
+
const reply = await session.sendAndWait({ prompt }, timeout);
|
|
118
|
+
// Use the final assistant message content, fall back to collected deltas
|
|
119
|
+
const output = reply?.data?.content ?? outputParts.join("");
|
|
120
|
+
const teammateNames = this.roster.map((r) => r.name);
|
|
121
|
+
return parseResult(teammate.name, output, teammateNames, prompt);
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
// Disconnect the session (preserves data for potential resume)
|
|
125
|
+
await session.disconnect().catch(() => { });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async routeTask(task, roster) {
|
|
129
|
+
await this.ensureClient();
|
|
130
|
+
const lines = [
|
|
131
|
+
"You are a task router. Given a task and a list of teammates, reply with ONLY the name of the teammate who should handle it. No explanation, no punctuation — just the name.",
|
|
132
|
+
"",
|
|
133
|
+
"Teammates:",
|
|
134
|
+
];
|
|
135
|
+
for (const t of roster) {
|
|
136
|
+
const owns = t.ownership.primary.length > 0
|
|
137
|
+
? ` — owns: ${t.ownership.primary.join(", ")}`
|
|
138
|
+
: "";
|
|
139
|
+
lines.push(`- ${t.name}: ${t.role}${owns}`);
|
|
140
|
+
}
|
|
141
|
+
lines.push("", `Task: ${task}`);
|
|
142
|
+
const session = await this.client.createSession({
|
|
143
|
+
model: this.options.model,
|
|
144
|
+
onPermissionRequest: approveAll,
|
|
145
|
+
systemMessage: {
|
|
146
|
+
mode: "replace",
|
|
147
|
+
content: lines.join("\n"),
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
try {
|
|
151
|
+
const reply = await session.sendAndWait({ prompt: task }, 30_000);
|
|
152
|
+
const output = reply?.data?.content?.trim().toLowerCase() ??
|
|
153
|
+
"";
|
|
154
|
+
// Match against roster names
|
|
155
|
+
const rosterNames = roster.map((r) => r.name);
|
|
156
|
+
for (const name of rosterNames) {
|
|
157
|
+
if (output === name.toLowerCase() ||
|
|
158
|
+
output.endsWith(name.toLowerCase())) {
|
|
159
|
+
return name;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
for (const name of rosterNames) {
|
|
163
|
+
if (output.includes(name.toLowerCase())) {
|
|
164
|
+
return name;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
await session.disconnect().catch(() => { });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async destroySession(_sessionId) {
|
|
177
|
+
// Disconnect all sessions
|
|
178
|
+
for (const [, session] of this.sessions) {
|
|
179
|
+
await session.disconnect().catch(() => { });
|
|
180
|
+
}
|
|
181
|
+
this.sessions.clear();
|
|
182
|
+
// Stop the client
|
|
183
|
+
if (this.client) {
|
|
184
|
+
await this.client.stop().catch(() => { });
|
|
185
|
+
this.client = null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Ensure the CopilotClient is started.
|
|
190
|
+
*/
|
|
191
|
+
async ensureClient(cwd) {
|
|
192
|
+
if (this.client)
|
|
193
|
+
return;
|
|
194
|
+
// Suppress Node.js ExperimentalWarning (e.g. SQLite) in the SDK's
|
|
195
|
+
// CLI subprocess so it doesn't leak into the terminal UI.
|
|
196
|
+
const env = { ...process.env };
|
|
197
|
+
const existing = env.NODE_OPTIONS ?? "";
|
|
198
|
+
if (!existing.includes("--disable-warning=ExperimentalWarning")) {
|
|
199
|
+
env.NODE_OPTIONS = existing
|
|
200
|
+
? `${existing} --disable-warning=ExperimentalWarning`
|
|
201
|
+
: "--disable-warning=ExperimentalWarning";
|
|
202
|
+
}
|
|
203
|
+
this.client = new CopilotClient({
|
|
204
|
+
cwd: cwd ?? process.cwd(),
|
|
205
|
+
githubToken: this.options.githubToken,
|
|
206
|
+
env,
|
|
207
|
+
});
|
|
208
|
+
await this.client.start();
|
|
209
|
+
}
|
|
210
|
+
}
|
package/dist/adapters/echo.d.ts
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
* Useful for verifying orchestrator wiring, handoff logic, and CLI behavior.
|
|
6
6
|
*/
|
|
7
7
|
import type { AgentAdapter } from "../adapter.js";
|
|
8
|
-
import type {
|
|
8
|
+
import type { TaskResult, TeammateConfig } from "../types.js";
|
|
9
9
|
export declare class EchoAdapter implements AgentAdapter {
|
|
10
10
|
readonly name = "echo";
|
|
11
11
|
startSession(teammate: TeammateConfig): Promise<string>;
|
|
12
|
-
executeTask(
|
|
12
|
+
executeTask(_sessionId: string, teammate: TeammateConfig, prompt: string): Promise<TaskResult>;
|
|
13
13
|
}
|
package/dist/adapters/echo.js
CHANGED
|
@@ -11,13 +11,14 @@ export class EchoAdapter {
|
|
|
11
11
|
async startSession(teammate) {
|
|
12
12
|
return `echo-${teammate.name}-${nextId++}`;
|
|
13
13
|
}
|
|
14
|
-
async executeTask(
|
|
14
|
+
async executeTask(_sessionId, teammate, prompt) {
|
|
15
15
|
const fullPrompt = buildTeammatePrompt(teammate, prompt);
|
|
16
16
|
return {
|
|
17
17
|
teammate: teammate.name,
|
|
18
18
|
success: true,
|
|
19
19
|
summary: `[echo] ${teammate.name} received task (${prompt.length} chars)`,
|
|
20
20
|
changedFiles: [],
|
|
21
|
+
handoffs: [],
|
|
21
22
|
rawOutput: fullPrompt,
|
|
22
23
|
};
|
|
23
24
|
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import { describe,
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { EchoAdapter } from "./echo.js";
|
|
3
3
|
const teammate = {
|
|
4
4
|
name: "beacon",
|
|
5
5
|
role: "Platform engineer.",
|
|
6
6
|
soul: "# Beacon\n\nBeacon owns the recall package.",
|
|
7
|
-
|
|
7
|
+
wisdom: "",
|
|
8
8
|
dailyLogs: [],
|
|
9
|
+
weeklyLogs: [],
|
|
9
10
|
ownership: { primary: [], secondary: [] },
|
|
11
|
+
routingKeywords: [],
|
|
10
12
|
};
|
|
11
13
|
describe("EchoAdapter", () => {
|
|
12
14
|
it("has name 'echo'", () => {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure utility functions extracted from cli.ts for testability.
|
|
3
|
+
*/
|
|
4
|
+
/** Convert a Date to a human-readable relative time string. */
|
|
5
|
+
export declare function relativeTime(date: Date): string;
|
|
6
|
+
/** Word-wrap text to maxWidth, returning an array of lines. */
|
|
7
|
+
export declare function wrapLine(text: string, maxWidth: number): string[];
|
|
8
|
+
/**
|
|
9
|
+
* Find an @mention at the given cursor position in a line.
|
|
10
|
+
* Returns the partial text after '@', the position of '@', and the text before it,
|
|
11
|
+
* or null if no valid @mention is found.
|
|
12
|
+
*/
|
|
13
|
+
export declare function findAtMention(line: string, cursor: number): {
|
|
14
|
+
before: string;
|
|
15
|
+
partial: string;
|
|
16
|
+
atPos: number;
|
|
17
|
+
} | null;
|
|
18
|
+
/** Set of recognized image file extensions. */
|
|
19
|
+
export declare const IMAGE_EXTS: Set<string>;
|
|
20
|
+
/** Check if a string looks like an image file path. */
|
|
21
|
+
export declare function isImagePath(text: string): boolean;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure utility functions extracted from cli.ts for testability.
|
|
3
|
+
*/
|
|
4
|
+
/** Convert a Date to a human-readable relative time string. */
|
|
5
|
+
export function relativeTime(date) {
|
|
6
|
+
const diff = Date.now() - date.getTime();
|
|
7
|
+
const secs = Math.floor(diff / 1000);
|
|
8
|
+
if (secs < 60)
|
|
9
|
+
return `${secs}s ago`;
|
|
10
|
+
const mins = Math.floor(secs / 60);
|
|
11
|
+
if (mins < 60)
|
|
12
|
+
return `${mins}m ago`;
|
|
13
|
+
const hrs = Math.floor(mins / 60);
|
|
14
|
+
return `${hrs}h ago`;
|
|
15
|
+
}
|
|
16
|
+
/** Word-wrap text to maxWidth, returning an array of lines. */
|
|
17
|
+
export function wrapLine(text, maxWidth) {
|
|
18
|
+
if (text.length <= maxWidth)
|
|
19
|
+
return [text];
|
|
20
|
+
const lines = [];
|
|
21
|
+
let remaining = text;
|
|
22
|
+
while (remaining.length > maxWidth) {
|
|
23
|
+
let breakAt = remaining.lastIndexOf(" ", maxWidth);
|
|
24
|
+
if (breakAt <= 0)
|
|
25
|
+
breakAt = maxWidth;
|
|
26
|
+
lines.push(remaining.slice(0, breakAt));
|
|
27
|
+
remaining = remaining.slice(breakAt + (remaining[breakAt] === " " ? 1 : 0));
|
|
28
|
+
}
|
|
29
|
+
if (remaining)
|
|
30
|
+
lines.push(remaining);
|
|
31
|
+
return lines;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Find an @mention at the given cursor position in a line.
|
|
35
|
+
* Returns the partial text after '@', the position of '@', and the text before it,
|
|
36
|
+
* or null if no valid @mention is found.
|
|
37
|
+
*/
|
|
38
|
+
export function findAtMention(line, cursor) {
|
|
39
|
+
// Walk backward from cursor to find the nearest unescaped '@'
|
|
40
|
+
const left = line.slice(0, cursor);
|
|
41
|
+
const atPos = left.lastIndexOf("@");
|
|
42
|
+
if (atPos < 0)
|
|
43
|
+
return null;
|
|
44
|
+
// '@' must be at start of line or preceded by whitespace
|
|
45
|
+
if (atPos > 0 && !/\s/.test(line[atPos - 1]))
|
|
46
|
+
return null;
|
|
47
|
+
const partial = left.slice(atPos + 1);
|
|
48
|
+
// Partial must be a single token (no spaces)
|
|
49
|
+
if (/\s/.test(partial))
|
|
50
|
+
return null;
|
|
51
|
+
return { before: line.slice(0, atPos), partial, atPos };
|
|
52
|
+
}
|
|
53
|
+
/** Set of recognized image file extensions. */
|
|
54
|
+
export const IMAGE_EXTS = new Set([
|
|
55
|
+
".png",
|
|
56
|
+
".jpg",
|
|
57
|
+
".jpeg",
|
|
58
|
+
".gif",
|
|
59
|
+
".bmp",
|
|
60
|
+
".webp",
|
|
61
|
+
".svg",
|
|
62
|
+
".ico",
|
|
63
|
+
]);
|
|
64
|
+
/** Check if a string looks like an image file path. */
|
|
65
|
+
export function isImagePath(text) {
|
|
66
|
+
// Must look like a file path (contains slash or backslash, or starts with drive letter)
|
|
67
|
+
if (!/[/\\]/.test(text) && !/^[a-zA-Z]:/.test(text))
|
|
68
|
+
return false;
|
|
69
|
+
// Must not contain newlines
|
|
70
|
+
if (/\n/.test(text))
|
|
71
|
+
return false;
|
|
72
|
+
const ext = text.slice(text.lastIndexOf(".")).toLowerCase();
|
|
73
|
+
return IMAGE_EXTS.has(ext);
|
|
74
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { findAtMention, IMAGE_EXTS, isImagePath, relativeTime, wrapLine, } from "./cli-utils.js";
|
|
3
|
+
// ── relativeTime ────────────────────────────────────────────────────
|
|
4
|
+
describe("relativeTime", () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
vi.useFakeTimers();
|
|
7
|
+
});
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
vi.useRealTimers();
|
|
10
|
+
});
|
|
11
|
+
it("returns seconds ago for < 60s", () => {
|
|
12
|
+
vi.setSystemTime(new Date("2026-03-15T12:00:30Z"));
|
|
13
|
+
const date = new Date("2026-03-15T12:00:00Z");
|
|
14
|
+
expect(relativeTime(date)).toBe("30s ago");
|
|
15
|
+
});
|
|
16
|
+
it("returns 0s ago for just now", () => {
|
|
17
|
+
const now = new Date("2026-03-15T12:00:00Z");
|
|
18
|
+
vi.setSystemTime(now);
|
|
19
|
+
expect(relativeTime(now)).toBe("0s ago");
|
|
20
|
+
});
|
|
21
|
+
it("returns minutes ago for 1-59 minutes", () => {
|
|
22
|
+
vi.setSystemTime(new Date("2026-03-15T12:05:00Z"));
|
|
23
|
+
const date = new Date("2026-03-15T12:00:00Z");
|
|
24
|
+
expect(relativeTime(date)).toBe("5m ago");
|
|
25
|
+
});
|
|
26
|
+
it("returns 59m ago at the boundary", () => {
|
|
27
|
+
vi.setSystemTime(new Date("2026-03-15T12:59:59Z"));
|
|
28
|
+
const date = new Date("2026-03-15T12:00:00Z");
|
|
29
|
+
expect(relativeTime(date)).toBe("59m ago");
|
|
30
|
+
});
|
|
31
|
+
it("returns hours ago for >= 60 minutes", () => {
|
|
32
|
+
vi.setSystemTime(new Date("2026-03-15T15:00:00Z"));
|
|
33
|
+
const date = new Date("2026-03-15T12:00:00Z");
|
|
34
|
+
expect(relativeTime(date)).toBe("3h ago");
|
|
35
|
+
});
|
|
36
|
+
it("returns large hour counts", () => {
|
|
37
|
+
vi.setSystemTime(new Date("2026-03-16T12:00:00Z"));
|
|
38
|
+
const date = new Date("2026-03-15T12:00:00Z");
|
|
39
|
+
expect(relativeTime(date)).toBe("24h ago");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
// ── wrapLine ────────────────────────────────────────────────────────
|
|
43
|
+
describe("wrapLine", () => {
|
|
44
|
+
it("returns the text unchanged if it fits within maxWidth", () => {
|
|
45
|
+
expect(wrapLine("hello world", 80)).toEqual(["hello world"]);
|
|
46
|
+
});
|
|
47
|
+
it("returns single-element array for empty string", () => {
|
|
48
|
+
expect(wrapLine("", 40)).toEqual([""]);
|
|
49
|
+
});
|
|
50
|
+
it("wraps at the last space before maxWidth", () => {
|
|
51
|
+
const result = wrapLine("hello world foo bar", 11);
|
|
52
|
+
expect(result).toEqual(["hello world", "foo bar"]);
|
|
53
|
+
});
|
|
54
|
+
it("wraps long text into multiple lines", () => {
|
|
55
|
+
const text = "one two three four five six";
|
|
56
|
+
const result = wrapLine(text, 10);
|
|
57
|
+
// "one two" (7) fits, "three" next
|
|
58
|
+
expect(result.length).toBeGreaterThan(1);
|
|
59
|
+
for (const line of result) {
|
|
60
|
+
expect(line.length).toBeLessThanOrEqual(10);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
it("hard-breaks when no space is found", () => {
|
|
64
|
+
const result = wrapLine("abcdefghij", 5);
|
|
65
|
+
expect(result).toEqual(["abcde", "fghij"]);
|
|
66
|
+
});
|
|
67
|
+
it("handles text exactly at maxWidth", () => {
|
|
68
|
+
expect(wrapLine("12345", 5)).toEqual(["12345"]);
|
|
69
|
+
});
|
|
70
|
+
it("handles maxWidth of 1 with spaces", () => {
|
|
71
|
+
const result = wrapLine("a b", 1);
|
|
72
|
+
// Each character should end up on its own line
|
|
73
|
+
expect(result.length).toBeGreaterThan(1);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
// ── findAtMention ───────────────────────────────────────────────────
|
|
77
|
+
describe("findAtMention", () => {
|
|
78
|
+
it("returns null when no @ is present", () => {
|
|
79
|
+
expect(findAtMention("hello world", 5)).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
it("finds @mention at cursor position", () => {
|
|
82
|
+
const result = findAtMention("@beacon", 4);
|
|
83
|
+
expect(result).toEqual({ before: "", partial: "bea", atPos: 0 });
|
|
84
|
+
});
|
|
85
|
+
it("finds @mention at end of input", () => {
|
|
86
|
+
const result = findAtMention("@beacon", 7);
|
|
87
|
+
expect(result).toEqual({ before: "", partial: "beacon", atPos: 0 });
|
|
88
|
+
});
|
|
89
|
+
it("finds @mention after text", () => {
|
|
90
|
+
const result = findAtMention("hello @scribe", 13);
|
|
91
|
+
expect(result).toEqual({ before: "hello ", partial: "scribe", atPos: 6 });
|
|
92
|
+
});
|
|
93
|
+
it("returns null when @ is preceded by non-whitespace", () => {
|
|
94
|
+
expect(findAtMention("email@example", 13)).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
it("returns empty partial when cursor is right after @", () => {
|
|
97
|
+
const result = findAtMention("hello @", 7);
|
|
98
|
+
expect(result).toEqual({ before: "hello ", partial: "", atPos: 6 });
|
|
99
|
+
});
|
|
100
|
+
it("returns null when partial contains spaces", () => {
|
|
101
|
+
// "@ foo bar" — after @, there's a space, so partial has whitespace
|
|
102
|
+
expect(findAtMention("@ foo bar", 9)).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
it("finds mention at start of line with cursor mid-word", () => {
|
|
105
|
+
const result = findAtMention("@sc", 3);
|
|
106
|
+
expect(result).toEqual({ before: "", partial: "sc", atPos: 0 });
|
|
107
|
+
});
|
|
108
|
+
it("finds the last @ when multiple exist", () => {
|
|
109
|
+
const result = findAtMention("@beacon hello @scribe", 21);
|
|
110
|
+
expect(result).toEqual({
|
|
111
|
+
before: "@beacon hello ",
|
|
112
|
+
partial: "scribe",
|
|
113
|
+
atPos: 14,
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
// ── isImagePath ─────────────────────────────────────────────────────
|
|
118
|
+
describe("isImagePath", () => {
|
|
119
|
+
it("recognizes Unix-style image paths", () => {
|
|
120
|
+
expect(isImagePath("/home/user/photo.png")).toBe(true);
|
|
121
|
+
expect(isImagePath("/tmp/image.jpg")).toBe(true);
|
|
122
|
+
expect(isImagePath("/tmp/image.jpeg")).toBe(true);
|
|
123
|
+
expect(isImagePath("/tmp/image.gif")).toBe(true);
|
|
124
|
+
expect(isImagePath("/tmp/image.bmp")).toBe(true);
|
|
125
|
+
expect(isImagePath("/tmp/image.webp")).toBe(true);
|
|
126
|
+
expect(isImagePath("/tmp/image.svg")).toBe(true);
|
|
127
|
+
expect(isImagePath("/tmp/image.ico")).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
it("recognizes Windows-style image paths", () => {
|
|
130
|
+
expect(isImagePath("C:\\Users\\me\\photo.png")).toBe(true);
|
|
131
|
+
expect(isImagePath("D:\\images\\test.jpg")).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
it("recognizes drive-letter paths without backslash", () => {
|
|
134
|
+
expect(isImagePath("C:photo.png")).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
it("is case-insensitive for extensions", () => {
|
|
137
|
+
expect(isImagePath("/tmp/photo.PNG")).toBe(true);
|
|
138
|
+
expect(isImagePath("/tmp/photo.Jpg")).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
it("rejects non-image extensions", () => {
|
|
141
|
+
expect(isImagePath("/tmp/file.txt")).toBe(false);
|
|
142
|
+
expect(isImagePath("/tmp/file.ts")).toBe(false);
|
|
143
|
+
expect(isImagePath("/tmp/file.pdf")).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
it("rejects strings without path separators or drive letters", () => {
|
|
146
|
+
expect(isImagePath("photo.png")).toBe(false);
|
|
147
|
+
expect(isImagePath("image.jpg")).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
it("rejects strings with newlines", () => {
|
|
150
|
+
expect(isImagePath("/tmp/photo.png\nextra")).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
it("rejects empty string", () => {
|
|
153
|
+
expect(isImagePath("")).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
it("rejects path with no extension", () => {
|
|
156
|
+
expect(isImagePath("/tmp/noext")).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
// ── IMAGE_EXTS ──────────────────────────────────────────────────────
|
|
160
|
+
describe("IMAGE_EXTS", () => {
|
|
161
|
+
it("contains expected extensions", () => {
|
|
162
|
+
const expected = [
|
|
163
|
+
".png",
|
|
164
|
+
".jpg",
|
|
165
|
+
".jpeg",
|
|
166
|
+
".gif",
|
|
167
|
+
".bmp",
|
|
168
|
+
".webp",
|
|
169
|
+
".svg",
|
|
170
|
+
".ico",
|
|
171
|
+
];
|
|
172
|
+
for (const ext of expected) {
|
|
173
|
+
expect(IMAGE_EXTS.has(ext)).toBe(true);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
it("has exactly 8 entries", () => {
|
|
177
|
+
expect(IMAGE_EXTS.size).toBe(8);
|
|
178
|
+
});
|
|
179
|
+
});
|