@nuvin/session 0.1.0-rc.5
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/dist/chunk-3IPZO7LP.js +66 -0
- package/dist/chunk-7G3R25NS.js +6 -0
- package/dist/chunk-DGC7JSCG.js +0 -0
- package/dist/chunk-FR3R66RD.js +678 -0
- package/dist/chunk-UVJIG4DT.js +17 -0
- package/dist/client/directory.d.ts +141 -0
- package/dist/client/directory.d.ts.map +1 -0
- package/dist/client/endpoint.d.ts +33 -0
- package/dist/client/endpoint.d.ts.map +1 -0
- package/dist/client/index.d.ts +8 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +1060 -0
- package/dist/client/session-client.d.ts +60 -0
- package/dist/client/session-client.d.ts.map +1 -0
- package/dist/client/socket.d.ts +38 -0
- package/dist/client/socket.d.ts.map +1 -0
- package/dist/client/transport.d.ts +9 -0
- package/dist/client/transport.d.ts.map +1 -0
- package/dist/client/uds-socket.d.ts +23 -0
- package/dist/client/uds-socket.d.ts.map +1 -0
- package/dist/client/websocket.d.ts +54 -0
- package/dist/client/websocket.d.ts.map +1 -0
- package/dist/controller/agent-channel.d.ts +58 -0
- package/dist/controller/agent-channel.d.ts.map +1 -0
- package/dist/controller/index.d.ts +3 -0
- package/dist/controller/index.d.ts.map +1 -0
- package/dist/controller/index.js +441 -0
- package/dist/controller/session-controller.d.ts +147 -0
- package/dist/controller/session-controller.d.ts.map +1 -0
- package/dist/controller/test-utils.d.ts +15 -0
- package/dist/controller/test-utils.d.ts.map +1 -0
- package/dist/grant/index.d.ts +33 -0
- package/dist/grant/index.d.ts.map +1 -0
- package/dist/grant/index.js +12 -0
- package/dist/protocol/index.d.ts +3 -0
- package/dist/protocol/index.d.ts.map +1 -0
- package/dist/protocol/index.js +7 -0
- package/dist/protocol/types.d.ts +855 -0
- package/dist/protocol/types.d.ts.map +1 -0
- package/dist/protocol/version.d.ts +8 -0
- package/dist/protocol/version.d.ts.map +1 -0
- package/dist/state/approvals.d.ts +34 -0
- package/dist/state/approvals.d.ts.map +1 -0
- package/dist/state/dir-access.d.ts +9 -0
- package/dist/state/dir-access.d.ts.map +1 -0
- package/dist/state/index.d.ts +6 -0
- package/dist/state/index.d.ts.map +1 -0
- package/dist/state/index.js +48 -0
- package/dist/state/json.d.ts +7 -0
- package/dist/state/json.d.ts.map +1 -0
- package/dist/state/messages.d.ts +105 -0
- package/dist/state/messages.d.ts.map +1 -0
- package/dist/state/session.d.ts +9 -0
- package/dist/state/session.d.ts.map +1 -0
- package/dist/state/tool-preview.d.ts +31 -0
- package/dist/state/tool-preview.d.ts.map +1 -0
- package/dist/state/tool-preview.js +300 -0
- package/dist/state/workflow-view.d.ts +10 -0
- package/dist/state/workflow-view.d.ts.map +1 -0
- package/dist/test-utils/fake-relay.d.ts +24 -0
- package/dist/test-utils/fake-relay.d.ts.map +1 -0
- package/dist/test-utils/index.d.ts +2 -0
- package/dist/test-utils/index.d.ts.map +1 -0
- package/dist/test-utils/index.js +250 -0
- package/dist/ui/history-grouping.d.ts +36 -0
- package/dist/ui/history-grouping.d.ts.map +1 -0
- package/dist/ui/index.d.ts +3 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +135 -0
- package/dist/ui/picker-rows.d.ts +62 -0
- package/dist/ui/picker-rows.d.ts.map +1 -0
- package/package.json +70 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { PROTOCOL_VERSION } from "../protocol/index.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Scripted stand-in for the @nuvin/server relay (extraction spec §4 stage 1c):
|
|
4
|
+
* exactly the REST + WS surface the CLIENT-side tests exercise — device-code
|
|
5
|
+
* registration, daemon list/connect/revoke, /control + /tunnel/:id +
|
|
6
|
+
* /connect/:id with a byte-splice. Auth mirrors the relay's StubTokenVerifier
|
|
7
|
+
* (`Bearer test:<userId>`). Grants are REAL (signGrant) so the daemon verifies
|
|
8
|
+
* them end-to-end. Live daemon↔relay e2e against the real relay lives in the
|
|
9
|
+
* nuvin-server repo — this fake exists so client tests never import it.
|
|
10
|
+
*/
|
|
11
|
+
export type FakeRelay = {
|
|
12
|
+
base: string;
|
|
13
|
+
port: number;
|
|
14
|
+
publicKey: string;
|
|
15
|
+
protocolVersion: typeof PROTOCOL_VERSION;
|
|
16
|
+
isOnline(daemonId: string): boolean;
|
|
17
|
+
/** Close every live channel socket pair — simulates a relay restart. */
|
|
18
|
+
closeAllChannels(): void;
|
|
19
|
+
close(): Promise<void>;
|
|
20
|
+
};
|
|
21
|
+
export declare function startFakeRelay(options?: {
|
|
22
|
+
grantTtlMs?: number;
|
|
23
|
+
}): Promise<FakeRelay>;
|
|
24
|
+
//# sourceMappingURL=fake-relay.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fake-relay.d.ts","sourceRoot":"","sources":["../../src/test-utils/fake-relay.ts"],"names":[],"mappings":"AAaA,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAGxD;;;;;;;;GAQG;AAEH,MAAM,MAAM,SAAS,GAAG;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,eAAe,EAAE,OAAO,gBAAgB,CAAC;IACzC,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC;IACpC,wEAAwE;IACxE,gBAAgB,IAAI,IAAI,CAAC;IACzB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB,CAAC;AAgDF,wBAAsB,cAAc,CAAC,OAAO,GAAE;IAAE,UAAU,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,OAAO,CAAC,SAAS,CAAC,CAwO9F"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/test-utils/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC"}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import "../chunk-DGC7JSCG.js";
|
|
2
|
+
import {
|
|
3
|
+
PROTOCOL_VERSION
|
|
4
|
+
} from "../chunk-7G3R25NS.js";
|
|
5
|
+
import {
|
|
6
|
+
generateGrantKeyPair,
|
|
7
|
+
signGrant
|
|
8
|
+
} from "../chunk-3IPZO7LP.js";
|
|
9
|
+
|
|
10
|
+
// src/test-utils/fake-relay.ts
|
|
11
|
+
import { randomUUID } from "crypto";
|
|
12
|
+
import { once } from "events";
|
|
13
|
+
import {
|
|
14
|
+
createServer
|
|
15
|
+
} from "http";
|
|
16
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
17
|
+
var userIdFromAuth = (request) => {
|
|
18
|
+
const header = request.headers.authorization;
|
|
19
|
+
if (typeof header !== "string" || !header.startsWith("Bearer test:")) return null;
|
|
20
|
+
const userId = header.slice("Bearer test:".length);
|
|
21
|
+
return userId.length > 0 ? userId : null;
|
|
22
|
+
};
|
|
23
|
+
var sendJson = (res, status, body) => {
|
|
24
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
25
|
+
res.end(body === void 0 ? void 0 : JSON.stringify(body));
|
|
26
|
+
};
|
|
27
|
+
var readJson = async (req) => {
|
|
28
|
+
const chunks = [];
|
|
29
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
30
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
31
|
+
return text ? JSON.parse(text) : {};
|
|
32
|
+
};
|
|
33
|
+
var rejectUpgrade = (socket, status, message) => {
|
|
34
|
+
socket.write(`HTTP/1.1 ${status} ${message}\r
|
|
35
|
+
Connection: close\r
|
|
36
|
+
\r
|
|
37
|
+
`);
|
|
38
|
+
socket.destroy();
|
|
39
|
+
};
|
|
40
|
+
async function startFakeRelay(options = {}) {
|
|
41
|
+
const grantTtlMs = options.grantTtlMs ?? 6e4;
|
|
42
|
+
const keys = generateGrantKeyPair();
|
|
43
|
+
const daemons = /* @__PURE__ */ new Map();
|
|
44
|
+
const pending = /* @__PURE__ */ new Map();
|
|
45
|
+
const controls = /* @__PURE__ */ new Map();
|
|
46
|
+
const channels = /* @__PURE__ */ new Map();
|
|
47
|
+
let port = 0;
|
|
48
|
+
let base = "";
|
|
49
|
+
const httpServer = createServer();
|
|
50
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
51
|
+
const closeChannel = (channelId, code, reason) => {
|
|
52
|
+
const channel = channels.get(channelId);
|
|
53
|
+
if (!channel) return;
|
|
54
|
+
channels.delete(channelId);
|
|
55
|
+
channel.client?.close(code, reason);
|
|
56
|
+
channel.daemon?.close(code, reason);
|
|
57
|
+
};
|
|
58
|
+
const attachChannelSide = (channelId, side, ws) => {
|
|
59
|
+
const channel = channels.get(channelId);
|
|
60
|
+
if (!channel) {
|
|
61
|
+
ws.close(4404, "unknown-channel");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
channel[side] = ws;
|
|
65
|
+
const outbound = side === "client" ? channel.forClient : channel.forDaemon;
|
|
66
|
+
const inbound = side === "client" ? channel.forDaemon : channel.forClient;
|
|
67
|
+
for (const frame of outbound.splice(0)) ws.send(frame.data, { binary: frame.isBinary });
|
|
68
|
+
ws.on("message", (data, isBinary) => {
|
|
69
|
+
const peer = side === "client" ? channel.daemon : channel.client;
|
|
70
|
+
if (peer && peer.readyState === WebSocket.OPEN) peer.send(data, { binary: isBinary });
|
|
71
|
+
else inbound.push({ data, isBinary });
|
|
72
|
+
});
|
|
73
|
+
ws.on("close", () => closeChannel(channelId, 1e3, "peer-closed"));
|
|
74
|
+
};
|
|
75
|
+
httpServer.on("request", (req, res) => {
|
|
76
|
+
void (async () => {
|
|
77
|
+
const url = new URL(req.url ?? "/", "http://fake.invalid");
|
|
78
|
+
const route = `${req.method} ${url.pathname}`;
|
|
79
|
+
if (route === "POST /api/device/code") {
|
|
80
|
+
const body = await readJson(req);
|
|
81
|
+
const entry = {
|
|
82
|
+
deviceCode: randomUUID(),
|
|
83
|
+
userCode: randomUUID().slice(0, 8),
|
|
84
|
+
daemonName: String(body.daemonName ?? "daemon"),
|
|
85
|
+
claimedBy: null
|
|
86
|
+
};
|
|
87
|
+
pending.set(entry.deviceCode, entry);
|
|
88
|
+
sendJson(res, 200, {
|
|
89
|
+
deviceCode: entry.deviceCode,
|
|
90
|
+
userCode: entry.userCode,
|
|
91
|
+
verificationUrl: `${base}/link`,
|
|
92
|
+
interval: 1,
|
|
93
|
+
expiresIn: 900
|
|
94
|
+
});
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (route === "POST /api/device/claim") {
|
|
98
|
+
const userId = userIdFromAuth(req);
|
|
99
|
+
if (!userId) return sendJson(res, 401, { message: "unauthorized" });
|
|
100
|
+
const body = await readJson(req);
|
|
101
|
+
const entry = [...pending.values()].find((p) => p.userCode === body.userCode);
|
|
102
|
+
if (!entry) return sendJson(res, 404, { message: "unknown user code" });
|
|
103
|
+
entry.claimedBy = userId;
|
|
104
|
+
sendJson(res, 200, { daemonName: entry.daemonName });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (route === "POST /api/device/token") {
|
|
108
|
+
const body = await readJson(req);
|
|
109
|
+
const entry = pending.get(String(body.deviceCode));
|
|
110
|
+
if (!entry) return sendJson(res, 404, { message: "unknown device code" });
|
|
111
|
+
if (!entry.claimedBy) return sendJson(res, 200, { status: "pending" });
|
|
112
|
+
pending.delete(entry.deviceCode);
|
|
113
|
+
const row = {
|
|
114
|
+
id: randomUUID(),
|
|
115
|
+
name: entry.daemonName,
|
|
116
|
+
deviceToken: randomUUID(),
|
|
117
|
+
userId: entry.claimedBy,
|
|
118
|
+
revoked: false
|
|
119
|
+
};
|
|
120
|
+
daemons.set(row.id, row);
|
|
121
|
+
sendJson(res, 200, {
|
|
122
|
+
status: "complete",
|
|
123
|
+
daemonId: row.id,
|
|
124
|
+
deviceToken: row.deviceToken,
|
|
125
|
+
serverPublicKey: keys.publicKey,
|
|
126
|
+
serverUrl: base
|
|
127
|
+
});
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (route === "GET /api/daemons") {
|
|
131
|
+
const userId = userIdFromAuth(req);
|
|
132
|
+
if (!userId) return sendJson(res, 401, { message: "unauthorized" });
|
|
133
|
+
const rows = [...daemons.values()].filter((row) => row.userId === userId && !row.revoked).map((row) => ({
|
|
134
|
+
id: row.id,
|
|
135
|
+
name: row.name,
|
|
136
|
+
online: controls.has(row.id),
|
|
137
|
+
lastSeenAt: null,
|
|
138
|
+
sessions: []
|
|
139
|
+
}));
|
|
140
|
+
sendJson(res, 200, rows);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const connectMatch = /^POST \/api\/daemons\/([A-Za-z0-9_-]+)\/connect$/.exec(route);
|
|
144
|
+
if (connectMatch?.[1]) {
|
|
145
|
+
const userId = userIdFromAuth(req);
|
|
146
|
+
if (!userId) return sendJson(res, 401, { message: "unauthorized" });
|
|
147
|
+
const row = daemons.get(connectMatch[1]);
|
|
148
|
+
if (!row || row.revoked || row.userId !== userId) {
|
|
149
|
+
return sendJson(res, 404, { message: "unknown daemon" });
|
|
150
|
+
}
|
|
151
|
+
const control = controls.get(row.id);
|
|
152
|
+
if (!control) return sendJson(res, 409, { message: "daemon-offline" });
|
|
153
|
+
const channelId = randomUUID();
|
|
154
|
+
channels.set(channelId, {
|
|
155
|
+
daemonId: row.id,
|
|
156
|
+
client: null,
|
|
157
|
+
daemon: null,
|
|
158
|
+
forDaemon: [],
|
|
159
|
+
forClient: []
|
|
160
|
+
});
|
|
161
|
+
const issuedAt = Date.now();
|
|
162
|
+
const payload = {
|
|
163
|
+
daemonId: row.id,
|
|
164
|
+
userId,
|
|
165
|
+
grantId: randomUUID(),
|
|
166
|
+
issuedAt,
|
|
167
|
+
expiresAt: issuedAt + grantTtlMs
|
|
168
|
+
};
|
|
169
|
+
control.send(JSON.stringify({ type: "open-channel", channelId }));
|
|
170
|
+
sendJson(res, 200, {
|
|
171
|
+
wsUrl: `ws://127.0.0.1:${port}/connect/${channelId}`,
|
|
172
|
+
grant: signGrant(payload, keys.privateKey)
|
|
173
|
+
});
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const revokeMatch = /^DELETE \/api\/daemons\/([A-Za-z0-9_-]+)$/.exec(route);
|
|
177
|
+
if (revokeMatch?.[1]) {
|
|
178
|
+
const userId = userIdFromAuth(req);
|
|
179
|
+
if (!userId) return sendJson(res, 401, { message: "unauthorized" });
|
|
180
|
+
const row = daemons.get(revokeMatch[1]);
|
|
181
|
+
if (!row || row.userId !== userId) return sendJson(res, 404, { message: "unknown daemon" });
|
|
182
|
+
row.revoked = true;
|
|
183
|
+
const control = controls.get(row.id);
|
|
184
|
+
control?.send(JSON.stringify({ type: "revoked" }));
|
|
185
|
+
control?.close(1e3, "revoked");
|
|
186
|
+
for (const [channelId, channel] of channels) {
|
|
187
|
+
if (channel.daemonId === row.id) closeChannel(channelId, 1e3, "revoked");
|
|
188
|
+
}
|
|
189
|
+
sendJson(res, 204);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
sendJson(res, 404, { message: "not found" });
|
|
193
|
+
})().catch(() => sendJson(res, 500, { message: "fake relay error" }));
|
|
194
|
+
});
|
|
195
|
+
httpServer.on("upgrade", (request, socket, head) => {
|
|
196
|
+
const pathname = new URL(request.url ?? "/", "http://fake.invalid").pathname;
|
|
197
|
+
if (pathname === "/control") {
|
|
198
|
+
const header = request.headers.authorization;
|
|
199
|
+
const token = typeof header === "string" && header.startsWith("Bearer ") ? header.slice(7) : null;
|
|
200
|
+
const row = token ? [...daemons.values()].find((d) => d.deviceToken === token && !d.revoked) : void 0;
|
|
201
|
+
if (!row) {
|
|
202
|
+
rejectUpgrade(socket, 401, "Unauthorized");
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
206
|
+
controls.set(row.id, ws);
|
|
207
|
+
ws.on("close", () => {
|
|
208
|
+
if (controls.get(row.id) === ws) controls.delete(row.id);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const connect = /^\/connect\/([A-Za-z0-9_-]+)$/.exec(pathname);
|
|
214
|
+
if (connect?.[1]) {
|
|
215
|
+
const channelId = connect[1];
|
|
216
|
+
wss.handleUpgrade(request, socket, head, (ws) => attachChannelSide(channelId, "client", ws));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const tunnel = /^\/tunnel\/([A-Za-z0-9_-]+)$/.exec(pathname);
|
|
220
|
+
if (tunnel?.[1]) {
|
|
221
|
+
const channelId = tunnel[1];
|
|
222
|
+
wss.handleUpgrade(request, socket, head, (ws) => attachChannelSide(channelId, "daemon", ws));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
rejectUpgrade(socket, 404, "Not Found");
|
|
226
|
+
});
|
|
227
|
+
httpServer.listen(0, "127.0.0.1");
|
|
228
|
+
await once(httpServer, "listening");
|
|
229
|
+
const address = httpServer.address();
|
|
230
|
+
port = typeof address === "object" && address !== null ? address.port : 0;
|
|
231
|
+
base = `http://127.0.0.1:${port}`;
|
|
232
|
+
return {
|
|
233
|
+
base,
|
|
234
|
+
port,
|
|
235
|
+
publicKey: keys.publicKey,
|
|
236
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
237
|
+
isOnline: (daemonId) => controls.has(daemonId),
|
|
238
|
+
closeAllChannels: () => {
|
|
239
|
+
for (const channelId of [...channels.keys()]) closeChannel(channelId, 1012, "relay-restart");
|
|
240
|
+
},
|
|
241
|
+
close: () => new Promise((resolve) => {
|
|
242
|
+
for (const client of wss.clients) client.terminate();
|
|
243
|
+
httpServer.closeAllConnections?.();
|
|
244
|
+
wss.close(() => httpServer.close(() => resolve()));
|
|
245
|
+
})
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
export {
|
|
249
|
+
startFakeRelay
|
|
250
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { HistorySummary } from "../protocol/types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Case-insensitive subsequence match (the history search predicate). An empty
|
|
4
|
+
* query matches everything; an undefined value never matches.
|
|
5
|
+
*/
|
|
6
|
+
export declare function fuzzyIncludes(value: string | undefined, query: string): boolean;
|
|
7
|
+
/** Epoch ms truncated to local midnight. */
|
|
8
|
+
export declare function startOfDay(value: number): number;
|
|
9
|
+
/**
|
|
10
|
+
* Date-bucket label for a history row. Does NOT collapse every older date to a
|
|
11
|
+
* bare weekday name (which repeats week-over-week and reads as ambiguous).
|
|
12
|
+
* Scheme:
|
|
13
|
+
* - Today / Yesterday
|
|
14
|
+
* - within the last 7 days → weekday name (unambiguous inside one week)
|
|
15
|
+
* - older → absolute date ("Jun 8", or "Jun 8, 2025" when the year differs)
|
|
16
|
+
*/
|
|
17
|
+
export declare function groupLabel(updatedAt: number, now?: number): string;
|
|
18
|
+
/**
|
|
19
|
+
* Relative timestamp for a history row. Today → "Just now" / "Nm ago" / "Nh
|
|
20
|
+
* ago"; previous days → "Nd ago" / "Nw ago" (capped at 5 weeks, then falls back
|
|
21
|
+
* to the absolute date so a 3-month-old session doesn't read "13w ago").
|
|
22
|
+
*/
|
|
23
|
+
export declare function formatSessionTime(updatedAt: number, now?: number): string;
|
|
24
|
+
/**
|
|
25
|
+
* Fuzzy-filter a daemon's cold-session list by title or id. A blank/whitespace
|
|
26
|
+
* query returns the list unchanged. Mirrors the history view's search predicate.
|
|
27
|
+
*/
|
|
28
|
+
export declare function filterHistory(items: HistorySummary[], query: string): HistorySummary[];
|
|
29
|
+
/**
|
|
30
|
+
* Select + order history rows for display (spec §7). Without showAll and with a
|
|
31
|
+
* known currentWorkspace, restrict to that workspace (the local-TUI default).
|
|
32
|
+
* Otherwise show every workspace, ordering the current workspace's rows first,
|
|
33
|
+
* then by recency. Web/remote (no currentWorkspace) always see everything.
|
|
34
|
+
*/
|
|
35
|
+
export declare function visibleHistory(items: HistorySummary[], currentWorkspace: string | undefined, showAll: boolean): HistorySummary[];
|
|
36
|
+
//# sourceMappingURL=history-grouping.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"history-grouping.d.ts","sourceRoot":"","sources":["../../src/ui/history-grouping.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAI3D;;;GAGG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAc/E;AAED,4CAA4C;AAC5C,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAIhD;AAED;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,SAAa,GAAG,MAAM,CActE;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,SAAa,GAAG,MAAM,CAqB7E;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,cAAc,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,cAAc,EAAE,CAMtF;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,cAAc,EAAE,EACvB,gBAAgB,EAAE,MAAM,GAAG,SAAS,EACpC,OAAO,EAAE,OAAO,GACf,cAAc,EAAE,CAalB"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/ui/index.ts"],"names":[],"mappings":"AAAA,cAAc,uBAAuB,CAAC;AACtC,cAAc,kBAAkB,CAAC"}
|
package/dist/ui/index.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// src/ui/history-grouping.ts
|
|
2
|
+
var DAY_MS = 864e5;
|
|
3
|
+
function fuzzyIncludes(value, query) {
|
|
4
|
+
if (!value) return false;
|
|
5
|
+
let queryIndex = 0;
|
|
6
|
+
const normalizedValue = value.toLowerCase();
|
|
7
|
+
const normalizedQuery = query.toLowerCase();
|
|
8
|
+
for (const char of normalizedValue) {
|
|
9
|
+
if (char === normalizedQuery[queryIndex]) {
|
|
10
|
+
queryIndex += 1;
|
|
11
|
+
if (queryIndex === normalizedQuery.length) return true;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return normalizedQuery.length === 0;
|
|
15
|
+
}
|
|
16
|
+
function startOfDay(value) {
|
|
17
|
+
const date = new Date(value);
|
|
18
|
+
date.setHours(0, 0, 0, 0);
|
|
19
|
+
return date.getTime();
|
|
20
|
+
}
|
|
21
|
+
function groupLabel(updatedAt, now = Date.now()) {
|
|
22
|
+
const day = startOfDay(updatedAt);
|
|
23
|
+
const today = startOfDay(now);
|
|
24
|
+
if (day === today) return "Today";
|
|
25
|
+
if (day === today - DAY_MS) return "Yesterday";
|
|
26
|
+
if (day > today - 7 * DAY_MS) {
|
|
27
|
+
return new Date(updatedAt).toLocaleDateString(void 0, { weekday: "long" });
|
|
28
|
+
}
|
|
29
|
+
const sameYear = new Date(updatedAt).getFullYear() === new Date(now).getFullYear();
|
|
30
|
+
return new Date(updatedAt).toLocaleDateString(void 0, {
|
|
31
|
+
month: "short",
|
|
32
|
+
day: "numeric",
|
|
33
|
+
...sameYear ? {} : { year: "numeric" }
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function formatSessionTime(updatedAt, now = Date.now()) {
|
|
37
|
+
const diffMs = Math.max(0, now - updatedAt);
|
|
38
|
+
if (diffMs < 6e4) return "Just now";
|
|
39
|
+
const days = Math.floor(diffMs / DAY_MS);
|
|
40
|
+
if (days === 0) {
|
|
41
|
+
const hours = Math.floor(diffMs / 36e5);
|
|
42
|
+
if (hours > 0) return `${hours}h ago`;
|
|
43
|
+
return `${Math.max(1, Math.floor(diffMs / 6e4))}m ago`;
|
|
44
|
+
}
|
|
45
|
+
if (days === 1) return "1d ago";
|
|
46
|
+
if (days < 7) return `${days}d ago`;
|
|
47
|
+
const weeks = Math.floor(days / 7);
|
|
48
|
+
if (weeks === 1) return "1w ago";
|
|
49
|
+
if (weeks <= 5) return `${weeks}w ago`;
|
|
50
|
+
const sameYear = new Date(updatedAt).getFullYear() === new Date(now).getFullYear();
|
|
51
|
+
return new Date(updatedAt).toLocaleDateString(void 0, {
|
|
52
|
+
month: "short",
|
|
53
|
+
day: "numeric",
|
|
54
|
+
...sameYear ? {} : { year: "numeric" }
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function filterHistory(items, query) {
|
|
58
|
+
const needle = query.trim().toLowerCase();
|
|
59
|
+
if (!needle) return items;
|
|
60
|
+
return items.filter(
|
|
61
|
+
(item) => fuzzyIncludes(item.title, needle) || fuzzyIncludes(item.id, needle)
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
function visibleHistory(items, currentWorkspace, showAll) {
|
|
65
|
+
const scoped = !showAll && currentWorkspace ? items.filter((item) => item.workspace === currentWorkspace) : items;
|
|
66
|
+
return [...scoped].sort((a, b) => {
|
|
67
|
+
if (currentWorkspace) {
|
|
68
|
+
const aCur = a.workspace === currentWorkspace ? 0 : 1;
|
|
69
|
+
const bCur = b.workspace === currentWorkspace ? 0 : 1;
|
|
70
|
+
if (aCur !== bCur) return aCur - bCur;
|
|
71
|
+
}
|
|
72
|
+
return b.updatedAt - a.updatedAt;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// src/ui/picker-rows.ts
|
|
77
|
+
function isSelectable(row) {
|
|
78
|
+
return row.kind !== "header" && row.kind !== "status";
|
|
79
|
+
}
|
|
80
|
+
function buildPickerRows(input) {
|
|
81
|
+
const rows = [];
|
|
82
|
+
for (const server of input.directory.servers) {
|
|
83
|
+
rows.push({ kind: "header", label: server.name });
|
|
84
|
+
if (server.status.state === "connecting") {
|
|
85
|
+
rows.push({ kind: "status", text: "connecting\u2026" });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (server.status.state === "offline") {
|
|
89
|
+
rows.push({ kind: "status", text: `offline \u2014 ${server.status.error}` });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
rows.push({ kind: "new", server: server.name });
|
|
93
|
+
const sessions = [...server.status.sessions].sort(
|
|
94
|
+
(a, b) => b.lastActivityAt - a.lastActivityAt
|
|
95
|
+
);
|
|
96
|
+
for (const session of sessions) {
|
|
97
|
+
rows.push({ kind: "session", server: server.name, session });
|
|
98
|
+
}
|
|
99
|
+
rows.push({ kind: "history", server: server.name });
|
|
100
|
+
}
|
|
101
|
+
for (const issue of input.issues) {
|
|
102
|
+
rows.push({ kind: "header", label: issue.name });
|
|
103
|
+
rows.push({ kind: "status", text: `offline \u2014 ${issue.message}` });
|
|
104
|
+
}
|
|
105
|
+
return rows;
|
|
106
|
+
}
|
|
107
|
+
function selectableIndices(rows) {
|
|
108
|
+
const indices = [];
|
|
109
|
+
rows.forEach((row, index) => {
|
|
110
|
+
if (isSelectable(row)) indices.push(index);
|
|
111
|
+
});
|
|
112
|
+
return indices;
|
|
113
|
+
}
|
|
114
|
+
function sessionTitle(session) {
|
|
115
|
+
return session.topic ?? session.lastMessage ?? session.name;
|
|
116
|
+
}
|
|
117
|
+
function describeSession(session) {
|
|
118
|
+
const clients = `${session.attachedClients} client${session.attachedClients === 1 ? "" : "s"}`;
|
|
119
|
+
const label = sessionTitle(session);
|
|
120
|
+
const workspaceTag = session.workspace && session.workspace !== "default" ? ` [${session.workspace}]` : "";
|
|
121
|
+
return `${label}${workspaceTag} \xB7 ${session.status} \xB7 ${session.cwd} \xB7 ${clients}`;
|
|
122
|
+
}
|
|
123
|
+
export {
|
|
124
|
+
buildPickerRows,
|
|
125
|
+
describeSession,
|
|
126
|
+
filterHistory,
|
|
127
|
+
formatSessionTime,
|
|
128
|
+
fuzzyIncludes,
|
|
129
|
+
groupLabel,
|
|
130
|
+
isSelectable,
|
|
131
|
+
selectableIndices,
|
|
132
|
+
sessionTitle,
|
|
133
|
+
startOfDay,
|
|
134
|
+
visibleHistory
|
|
135
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { DirectoryView } from "../client/directory.ts";
|
|
2
|
+
import type { SessionDescriptor } from "../protocol/types.ts";
|
|
3
|
+
/**
|
|
4
|
+
* A configured-server entry that could not be resolved, with an actionable
|
|
5
|
+
* reason. The picker renders these as offline(error) sections; they never block
|
|
6
|
+
* anything. Structurally identical to the CLI's `ServerResolutionIssue`, defined
|
|
7
|
+
* locally so this module stays dependency-free (consumable by TUI and web).
|
|
8
|
+
*/
|
|
9
|
+
export type PickerIssue = {
|
|
10
|
+
name: string;
|
|
11
|
+
message: string;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Uniform dashboard row model (spec §4). One section per daemon (Local +
|
|
15
|
+
* configured servers): a header, then either a connecting/offline status row,
|
|
16
|
+
* or — when online — a New-session row, the daemon's live sessions, and a
|
|
17
|
+
* single History entry-point row that opens the per-daemon history view WITHOUT
|
|
18
|
+
* entering a session. There are NO resumable-history rows in the dashboard
|
|
19
|
+
* (history lives in the history view).
|
|
20
|
+
*/
|
|
21
|
+
export type PickerRow = {
|
|
22
|
+
kind: "header";
|
|
23
|
+
label: string;
|
|
24
|
+
} | {
|
|
25
|
+
kind: "status";
|
|
26
|
+
text: string;
|
|
27
|
+
} | {
|
|
28
|
+
kind: "new";
|
|
29
|
+
server: string;
|
|
30
|
+
} | {
|
|
31
|
+
kind: "session";
|
|
32
|
+
server: string;
|
|
33
|
+
session: SessionDescriptor;
|
|
34
|
+
} | {
|
|
35
|
+
kind: "history";
|
|
36
|
+
server: string;
|
|
37
|
+
};
|
|
38
|
+
export declare function isSelectable(row: PickerRow): boolean;
|
|
39
|
+
export type BuildPickerRowsInput = {
|
|
40
|
+
directory: DirectoryView;
|
|
41
|
+
issues: PickerIssue[];
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Pure dashboard content model (spec §4). For each daemon in the directory:
|
|
45
|
+
* a header with the daemon name, then its connection status. An online daemon
|
|
46
|
+
* shows a New-session row, its live sessions (most-recently-active first), and
|
|
47
|
+
* a History entry-point row. A connecting/offline daemon shows only a status
|
|
48
|
+
* row (offline → sessions unknown). Unresolvable config entries (issues) render
|
|
49
|
+
* as offline sections after the connected daemons.
|
|
50
|
+
*/
|
|
51
|
+
export declare function buildPickerRows(input: BuildPickerRowsInput): PickerRow[];
|
|
52
|
+
/** Indices into `rows` for selectable rows, in display order. */
|
|
53
|
+
export declare function selectableIndices(rows: PickerRow[]): number[];
|
|
54
|
+
/**
|
|
55
|
+
* Display title for a live session (spec §4): the human-readable topic when
|
|
56
|
+
* set, else the first line of the most recent user message, else the
|
|
57
|
+
* (id-derived) session name. Shared by the TUI dashboard and the web client.
|
|
58
|
+
*/
|
|
59
|
+
export declare function sessionTitle(session: SessionDescriptor): string;
|
|
60
|
+
/** One-line live-session summary: title [workspace] · status · cwd · attached clients (spec §4). */
|
|
61
|
+
export declare function describeSession(session: SessionDescriptor): string;
|
|
62
|
+
//# sourceMappingURL=picker-rows.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"picker-rows.d.ts","sourceRoot":"","sources":["../../src/ui/picker-rows.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAC5D,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAE9D;;;;;GAKG;AACH,MAAM,MAAM,WAAW,GAAG;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF;;;;;;;GAOG;AACH,MAAM,MAAM,SAAS,GACjB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GACjC;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAChC;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAC/B;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,iBAAiB,CAAA;CAAE,GAC/D;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAExC,wBAAgB,YAAY,CAAC,GAAG,EAAE,SAAS,GAAG,OAAO,CAEpD;AAED,MAAM,MAAM,oBAAoB,GAAG;IACjC,SAAS,EAAE,aAAa,CAAC;IACzB,MAAM,EAAE,WAAW,EAAE,CAAC;CACvB,CAAC;AAEF;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,oBAAoB,GAAG,SAAS,EAAE,CA6BxE;AAED,iEAAiE;AACjE,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,SAAS,EAAE,GAAG,MAAM,EAAE,CAM7D;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,iBAAiB,GAAG,MAAM,CAE/D;AAED,oGAAoG;AACpG,wBAAgB,eAAe,CAAC,OAAO,EAAE,iBAAiB,GAAG,MAAM,CAQlE"}
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nuvin/session",
|
|
3
|
+
"version": "0.1.0-rc.5",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Headless session layer for Nuvin: state reducers, wire protocol, session controller",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"exports": {
|
|
18
|
+
"./state": {
|
|
19
|
+
"types": "./dist/state/index.d.ts",
|
|
20
|
+
"import": "./dist/state/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./tool-preview": {
|
|
23
|
+
"types": "./dist/state/tool-preview.d.ts",
|
|
24
|
+
"import": "./dist/state/tool-preview.js"
|
|
25
|
+
},
|
|
26
|
+
"./protocol": {
|
|
27
|
+
"types": "./dist/protocol/index.d.ts",
|
|
28
|
+
"import": "./dist/protocol/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./controller": {
|
|
31
|
+
"types": "./dist/controller/index.d.ts",
|
|
32
|
+
"import": "./dist/controller/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./client": {
|
|
35
|
+
"types": "./dist/client/index.d.ts",
|
|
36
|
+
"import": "./dist/client/index.js"
|
|
37
|
+
},
|
|
38
|
+
"./grant": {
|
|
39
|
+
"types": "./dist/grant/index.d.ts",
|
|
40
|
+
"import": "./dist/grant/index.js"
|
|
41
|
+
},
|
|
42
|
+
"./ui": {
|
|
43
|
+
"types": "./dist/ui/index.d.ts",
|
|
44
|
+
"import": "./dist/ui/index.js"
|
|
45
|
+
},
|
|
46
|
+
"./test-utils": {
|
|
47
|
+
"types": "./dist/test-utils/index.d.ts",
|
|
48
|
+
"import": "./dist/test-utils/index.js"
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly",
|
|
53
|
+
"dev": "tsup --watch",
|
|
54
|
+
"test": "vitest run",
|
|
55
|
+
"test:watch": "vitest",
|
|
56
|
+
"clean": "rm -rf dist",
|
|
57
|
+
"lint": "biome check ."
|
|
58
|
+
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"@nuvin/agent-core": "workspace:^",
|
|
61
|
+
"ws": "^8.20.0"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@types/node": "^22.0.0",
|
|
65
|
+
"@types/ws": "^8.18.1",
|
|
66
|
+
"tsup": "^8.5.1",
|
|
67
|
+
"typescript": "^6.0.2",
|
|
68
|
+
"vitest": "^4.1.2"
|
|
69
|
+
}
|
|
70
|
+
}
|