@xp266/dshtui 0.1.5 → 0.1.6
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/lib/index.mjs +20 -105
- package/package.json +1 -1
- package/src/chat/session-list.ts +29 -139
package/lib/index.mjs
CHANGED
|
@@ -7197,9 +7197,9 @@ function sessionTime(session) {
|
|
|
7197
7197
|
* with the client-side visibility rules: live sessions plus persisted cold
|
|
7198
7198
|
* sessions with a cwd, blank and subagent-origin rows hidden, newest-first,
|
|
7199
7199
|
* without any workspace filtering (the picker labels ungrouped sessions).
|
|
7200
|
-
* Cold rows
|
|
7201
|
-
*
|
|
7202
|
-
*
|
|
7200
|
+
* Cold rows take their titles from the host's batched session-title fold
|
|
7201
|
+
* (`sessionQuery.readTitleSnapshots`), which reads each log once per
|
|
7202
|
+
* release of the query corpus and never loads full sessions into memory.
|
|
7203
7203
|
*/
|
|
7204
7204
|
async function computeSessionList(ctx) {
|
|
7205
7205
|
try {
|
|
@@ -7224,8 +7224,7 @@ async function computeSessionListInner(ctx) {
|
|
|
7224
7224
|
const title = titleService?.get?.(session)?.title ?? firstUserText(session.snapshotEvents());
|
|
7225
7225
|
summaries.push(toSummary(id, title, session.header.cwd, session.header.createdAt, lastPromptAt(session.snapshotEvents()), workspacePaths));
|
|
7226
7226
|
}
|
|
7227
|
-
|
|
7228
|
-
await mergeColdSummaries(ctx, persistence, await listColdHeaders(ctx, persistence, attached), archived, workspacePaths, summaries);
|
|
7227
|
+
await mergeColdSummaries(ctx, await listColdHeaders(ctx, ctx.get("sessionPersistence"), attached), archived, workspacePaths, summaries);
|
|
7229
7228
|
await attachModifiedTimes(ctx, summaries);
|
|
7230
7229
|
return summaries.sort((a, b) => sessionTime(b) - sessionTime(a));
|
|
7231
7230
|
}
|
|
@@ -7286,102 +7285,26 @@ function lastPromptAt(events) {
|
|
|
7286
7285
|
for (const event of events) if (event.type === "user/message" && event.data.source.kind === "user") last = event.time;
|
|
7287
7286
|
return last;
|
|
7288
7287
|
}
|
|
7289
|
-
|
|
7290
|
-
const
|
|
7291
|
-
|
|
7292
|
-
|
|
7293
|
-
|
|
7294
|
-
|
|
7295
|
-
|
|
7296
|
-
|
|
7297
|
-
|
|
7298
|
-
|
|
7299
|
-
|
|
7300
|
-
|
|
7301
|
-
|
|
7302
|
-
function listMetadataHint(values) {
|
|
7303
|
-
const hint = values.sessionListMetadata;
|
|
7304
|
-
if (typeof hint?.blank !== "boolean") return void 0;
|
|
7305
|
-
return {
|
|
7306
|
-
blank: hint.blank,
|
|
7307
|
-
lastPromptAt: typeof hint.lastPromptAt === "number" ? hint.lastPromptAt : null
|
|
7308
|
-
};
|
|
7309
|
-
}
|
|
7310
|
-
/**
|
|
7311
|
-
* Cold-session metadata without unconditional full-log reads: projection
|
|
7312
|
-
* hints first (zero I/O), then a direct read only when the artifact is small
|
|
7313
|
-
* enough to be a blank-session log. A hint-less oversized artifact is
|
|
7314
|
-
* conservatively visible; its title comes from the projection cache's cold
|
|
7315
|
-
* ladder, which reads the log once and writes the row back durably.
|
|
7316
|
-
*/
|
|
7317
|
-
async function resolveColdMeta(cache, persistence, header) {
|
|
7318
|
-
let titleHint;
|
|
7319
|
-
let metadata;
|
|
7320
|
-
try {
|
|
7321
|
-
const values = cache?.cachedSnapshot?.(header)?.values ?? {};
|
|
7322
|
-
if (typeof values.title === "string" && values.title !== "") titleHint = values.title;
|
|
7323
|
-
metadata = listMetadataHint(values);
|
|
7324
|
-
} catch {}
|
|
7325
|
-
if (metadata?.blank === false) return {
|
|
7326
|
-
title: await coldTitle(cache, header, titleHint),
|
|
7327
|
-
blank: false,
|
|
7328
|
-
promptAt: metadata.lastPromptAt ?? 0
|
|
7329
|
-
};
|
|
7330
|
-
const probed = await probeSmallArtifact(persistence, header);
|
|
7331
|
-
if (probed !== void 0) return {
|
|
7332
|
-
title: probed.title ?? titleHint,
|
|
7333
|
-
blank: probed.blank,
|
|
7334
|
-
promptAt: probed.promptAt
|
|
7335
|
-
};
|
|
7336
|
-
return {
|
|
7337
|
-
title: await coldTitle(cache, header, titleHint),
|
|
7338
|
-
blank: false,
|
|
7339
|
-
promptAt: metadata?.lastPromptAt ?? 0
|
|
7340
|
-
};
|
|
7341
|
-
}
|
|
7342
|
-
async function coldTitle(cache, header, titleHint) {
|
|
7343
|
-
if (titleHint !== void 0 || cache?.coldSnapshot === void 0) return titleHint;
|
|
7344
|
-
try {
|
|
7345
|
-
const title = (await cache.coldSnapshot(String(header.id)))?.values?.title;
|
|
7346
|
-
return typeof title === "string" && title !== "" ? title : void 0;
|
|
7347
|
-
} catch {
|
|
7348
|
-
return;
|
|
7349
|
-
}
|
|
7350
|
-
}
|
|
7351
|
-
async function probeSmallArtifact(persistence, header) {
|
|
7352
|
-
if (persistence.locate === void 0 || persistence.readFrom === void 0) return void 0;
|
|
7353
|
-
const location = persistence.locate(header);
|
|
7354
|
-
if (location === void 0) return void 0;
|
|
7355
|
-
try {
|
|
7356
|
-
if ((await stat(location.path)).size > COLD_PROBE_MAX_BYTES) return void 0;
|
|
7357
|
-
const { events } = await persistence.readFrom(String(header.id), 0);
|
|
7358
|
-
return {
|
|
7359
|
-
blank: isBlankSession(events),
|
|
7360
|
-
title: titleFromEvents(events) ?? firstUserText(events),
|
|
7361
|
-
promptAt: lastPromptAt(events)
|
|
7362
|
-
};
|
|
7363
|
-
} catch {
|
|
7364
|
-
return;
|
|
7288
|
+
async function mergeColdSummaries(ctx, cold, archived, workspacePaths, summaries) {
|
|
7289
|
+
const query = ctx.get("sessionQuery");
|
|
7290
|
+
if (query?.readTitleSnapshots === void 0) return;
|
|
7291
|
+
const observed = await query.readTitleSnapshots(cold.map((header) => String(header.id))).catch(() => []);
|
|
7292
|
+
const titleById = /* @__PURE__ */ new Map();
|
|
7293
|
+
for (const entry of observed) {
|
|
7294
|
+
if (entry.status !== "fulfilled") continue;
|
|
7295
|
+
const snapshot = entry.value?.title;
|
|
7296
|
+
if (snapshot?.title === void 0 || snapshot.title === "") continue;
|
|
7297
|
+
titleById.set(String(entry.sessionId), {
|
|
7298
|
+
title: snapshot.title,
|
|
7299
|
+
promptAt: snapshot.updatedAt ?? 0
|
|
7300
|
+
});
|
|
7365
7301
|
}
|
|
7366
|
-
}
|
|
7367
|
-
async function mergeColdSummaries(ctx, persistence, cold, archived, workspacePaths, summaries) {
|
|
7368
|
-
if (persistence === void 0) return;
|
|
7369
|
-
const cache = ctx.get("sessionProjectionCache");
|
|
7370
|
-
const pending = [];
|
|
7371
7302
|
for (const header of cold) {
|
|
7372
7303
|
if (archived.has(String(header.id))) continue;
|
|
7373
|
-
const
|
|
7374
|
-
if (
|
|
7375
|
-
|
|
7376
|
-
continue;
|
|
7377
|
-
}
|
|
7378
|
-
pending.push(header);
|
|
7304
|
+
const meta = titleById.get(String(header.id));
|
|
7305
|
+
if (meta === void 0) continue;
|
|
7306
|
+
summaries.push(toSummary(String(header.id), meta.title, header.cwd, header.createdAt, meta.promptAt, workspacePaths));
|
|
7379
7307
|
}
|
|
7380
|
-
for (let index = 0; index < pending.length; index += PROBE_BATCH_SIZE) await Promise.all(pending.slice(index, index + PROBE_BATCH_SIZE).map(async (header) => {
|
|
7381
|
-
const meta = await resolveColdMeta(cache, persistence, header);
|
|
7382
|
-
cacheSessionMeta(String(header.id), meta);
|
|
7383
|
-
if (!meta.blank) summaries.push(toSummary(String(header.id), meta.title, header.cwd, header.createdAt, meta.promptAt, workspacePaths));
|
|
7384
|
-
}));
|
|
7385
7308
|
}
|
|
7386
7309
|
function fallbackName(id, directory) {
|
|
7387
7310
|
if (directory !== void 0 && directory !== "") {
|
|
@@ -7390,14 +7313,6 @@ function fallbackName(id, directory) {
|
|
|
7390
7313
|
}
|
|
7391
7314
|
return id.match(/[^/]+$/)?.[0] ?? id;
|
|
7392
7315
|
}
|
|
7393
|
-
function titleFromEvents(events) {
|
|
7394
|
-
let title;
|
|
7395
|
-
for (const event of events) if (event.type === "session/title") {
|
|
7396
|
-
const data = event.data;
|
|
7397
|
-
if (data?.title !== void 0 && data.title !== "") title = data.title;
|
|
7398
|
-
}
|
|
7399
|
-
return title;
|
|
7400
|
-
}
|
|
7401
7316
|
function firstUserText(events) {
|
|
7402
7317
|
for (const event of events) {
|
|
7403
7318
|
if (event.type !== "user/message") continue;
|
package/package.json
CHANGED
package/src/chat/session-list.ts
CHANGED
|
@@ -23,9 +23,9 @@ export function sessionTime(session: SessionSummary): number {
|
|
|
23
23
|
* with the client-side visibility rules: live sessions plus persisted cold
|
|
24
24
|
* sessions with a cwd, blank and subagent-origin rows hidden, newest-first,
|
|
25
25
|
* without any workspace filtering (the picker labels ungrouped sessions).
|
|
26
|
-
* Cold rows
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
* Cold rows take their titles from the host's batched session-title fold
|
|
27
|
+
* (`sessionQuery.readTitleSnapshots`), which reads each log once per
|
|
28
|
+
* release of the query corpus and never loads full sessions into memory.
|
|
29
29
|
*/
|
|
30
30
|
export async function computeSessionList(ctx: Context): Promise<SessionSummary[]> {
|
|
31
31
|
try {
|
|
@@ -55,7 +55,7 @@ async function computeSessionListInner(ctx: Context): Promise<SessionSummary[]>
|
|
|
55
55
|
}
|
|
56
56
|
const persistence = ctx.get('sessionPersistence') as PersistenceLike | undefined
|
|
57
57
|
const cold = await listColdHeaders(ctx, persistence, attached)
|
|
58
|
-
await mergeColdSummaries(ctx,
|
|
58
|
+
await mergeColdSummaries(ctx, cold, archived, workspacePaths, summaries)
|
|
59
59
|
await attachModifiedTimes(ctx, summaries)
|
|
60
60
|
return summaries.sort((a, b) => sessionTime(b) - sessionTime(a))
|
|
61
61
|
}
|
|
@@ -67,13 +67,18 @@ interface ColdHeader {
|
|
|
67
67
|
origin?: string
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
interface
|
|
71
|
-
|
|
70
|
+
interface PersistenceLike {
|
|
71
|
+
list(signal?: AbortSignal): Promise<ColdHeader[]>
|
|
72
|
+
locate?(header: { cwd?: string; id: string }): { path: string } | undefined
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
interface
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
interface SessionQueryLike {
|
|
76
|
+
listSessions?(signal?: AbortSignal): Promise<Array<{ header: ColdHeader; live: boolean; persisted: boolean }>>
|
|
77
|
+
readTitleSnapshots?(sessionIds: readonly string[]): Promise<Array<{
|
|
78
|
+
sessionId: string
|
|
79
|
+
status: 'fulfilled' | 'rejected'
|
|
80
|
+
value?: { title?: { title?: string; updatedAt?: number } }
|
|
81
|
+
}>>
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
/**
|
|
@@ -159,134 +164,30 @@ function lastPromptAt(events: readonly SessionEvent[]): number {
|
|
|
159
164
|
return last
|
|
160
165
|
}
|
|
161
166
|
|
|
162
|
-
interface PersistenceLike {
|
|
163
|
-
list(signal?: AbortSignal): Promise<ColdHeader[]>
|
|
164
|
-
locate?(header: { cwd?: string; id: string }): { path: string } | undefined
|
|
165
|
-
readFrom?(id: string, offset: number, signal?: AbortSignal): Promise<{ events: SessionEvent[] }>
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
interface SessionMetaCacheEntry {
|
|
169
|
-
title: string | undefined
|
|
170
|
-
blank: boolean | undefined
|
|
171
|
-
promptAt: number
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const SESSION_META_CACHE_MAX = 512
|
|
175
|
-
const sessionMetaCache = new Map<string, SessionMetaCacheEntry>()
|
|
176
|
-
|
|
177
|
-
function cacheSessionMeta(id: string, entry: SessionMetaCacheEntry): void {
|
|
178
|
-
while (sessionMetaCache.size >= SESSION_META_CACHE_MAX) {
|
|
179
|
-
const oldest = sessionMetaCache.keys().next()
|
|
180
|
-
if (oldest.done) break
|
|
181
|
-
sessionMetaCache.delete(oldest.value)
|
|
182
|
-
}
|
|
183
|
-
sessionMetaCache.set(id, entry)
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const PROBE_BATCH_SIZE = 16
|
|
187
|
-
|
|
188
|
-
/** Mirrors the host's cold blank-probe gate: only small artifacts are read. */
|
|
189
|
-
const COLD_PROBE_MAX_BYTES = 1024
|
|
190
|
-
|
|
191
|
-
interface ColdMeta {
|
|
192
|
-
title: string | undefined
|
|
193
|
-
blank: boolean
|
|
194
|
-
promptAt: number
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function listMetadataHint(values: Record<string, unknown>): { blank: boolean; lastPromptAt: number | null } | undefined {
|
|
198
|
-
const hint = values.sessionListMetadata as { blank?: unknown; lastPromptAt?: unknown } | undefined
|
|
199
|
-
if (typeof hint?.blank !== 'boolean') return undefined
|
|
200
|
-
return {
|
|
201
|
-
blank: hint.blank,
|
|
202
|
-
lastPromptAt: typeof hint.lastPromptAt === 'number' ? hint.lastPromptAt : null,
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Cold-session metadata without unconditional full-log reads: projection
|
|
208
|
-
* hints first (zero I/O), then a direct read only when the artifact is small
|
|
209
|
-
* enough to be a blank-session log. A hint-less oversized artifact is
|
|
210
|
-
* conservatively visible; its title comes from the projection cache's cold
|
|
211
|
-
* ladder, which reads the log once and writes the row back durably.
|
|
212
|
-
*/
|
|
213
|
-
async function resolveColdMeta(cache: ProjectionCacheLike | undefined, persistence: PersistenceLike, header: ColdHeader): Promise<ColdMeta> {
|
|
214
|
-
let titleHint: string | undefined
|
|
215
|
-
let metadata: { blank: boolean; lastPromptAt: number | null } | undefined
|
|
216
|
-
try {
|
|
217
|
-
const values = cache?.cachedSnapshot?.(header)?.values ?? {}
|
|
218
|
-
if (typeof values.title === 'string' && values.title !== '') titleHint = values.title
|
|
219
|
-
metadata = listMetadataHint(values)
|
|
220
|
-
} catch {
|
|
221
|
-
// A broken projection cache must not block the session picker; fall back to probing.
|
|
222
|
-
}
|
|
223
|
-
if (metadata?.blank === false) {
|
|
224
|
-
return { title: await coldTitle(cache, header, titleHint), blank: false, promptAt: metadata.lastPromptAt ?? 0 }
|
|
225
|
-
}
|
|
226
|
-
const probed = await probeSmallArtifact(persistence, header)
|
|
227
|
-
if (probed !== undefined) {
|
|
228
|
-
return { title: probed.title ?? titleHint, blank: probed.blank, promptAt: probed.promptAt }
|
|
229
|
-
}
|
|
230
|
-
return { title: await coldTitle(cache, header, titleHint), blank: false, promptAt: metadata?.lastPromptAt ?? 0 }
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
async function coldTitle(cache: ProjectionCacheLike | undefined, header: ColdHeader, titleHint: string | undefined): Promise<string | undefined> {
|
|
234
|
-
if (titleHint !== undefined || cache?.coldSnapshot === undefined) return titleHint
|
|
235
|
-
try {
|
|
236
|
-
const snapshot = await cache.coldSnapshot(String(header.id))
|
|
237
|
-
const title = snapshot?.values?.title
|
|
238
|
-
return typeof title === 'string' && title !== '' ? title : undefined
|
|
239
|
-
} catch {
|
|
240
|
-
// A failed cold-snapshot read leaves the session without a title.
|
|
241
|
-
return undefined
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
async function probeSmallArtifact(persistence: PersistenceLike, header: ColdHeader): Promise<ColdMeta | undefined> {
|
|
246
|
-
if (persistence.locate === undefined || persistence.readFrom === undefined) return undefined
|
|
247
|
-
const location = persistence.locate(header)
|
|
248
|
-
if (location === undefined) return undefined
|
|
249
|
-
try {
|
|
250
|
-
const info = await stat(location.path)
|
|
251
|
-
if (info.size > COLD_PROBE_MAX_BYTES) return undefined
|
|
252
|
-
const { events } = await persistence.readFrom(String(header.id), 0)
|
|
253
|
-
return { blank: isBlankSession(events), title: titleFromEvents(events) ?? firstUserText(events), promptAt: lastPromptAt(events) }
|
|
254
|
-
} catch {
|
|
255
|
-
// An unreadable artifact is treated as a blank session (skipped in the list).
|
|
256
|
-
return undefined
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
167
|
async function mergeColdSummaries(
|
|
261
168
|
ctx: Context,
|
|
262
|
-
persistence: PersistenceLike | undefined,
|
|
263
169
|
cold: ColdHeader[],
|
|
264
170
|
archived: ReadonlySet<string>,
|
|
265
171
|
workspacePaths: ReadonlySet<string>,
|
|
266
172
|
summaries: SessionSummary[],
|
|
267
173
|
): Promise<void> {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const
|
|
174
|
+
const query = ctx.get('sessionQuery') as SessionQueryLike | undefined
|
|
175
|
+
if (query?.readTitleSnapshots === undefined) return
|
|
176
|
+
const observed = await query.readTitleSnapshots(cold.map(header => String(header.id))).catch(() => [])
|
|
177
|
+
const titleById = new Map<string, { title: string; promptAt: number }>()
|
|
178
|
+
for (const entry of observed) {
|
|
179
|
+
if (entry.status !== 'fulfilled') continue
|
|
180
|
+
const snapshot = entry.value?.title
|
|
181
|
+
if (snapshot?.title === undefined || snapshot.title === '') continue
|
|
182
|
+
titleById.set(String(entry.sessionId), { title: snapshot.title, promptAt: snapshot.updatedAt ?? 0 })
|
|
183
|
+
}
|
|
271
184
|
for (const header of cold) {
|
|
272
185
|
if (archived.has(String(header.id))) continue
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
continue
|
|
279
|
-
}
|
|
280
|
-
pending.push(header)
|
|
281
|
-
}
|
|
282
|
-
for (let index = 0; index < pending.length; index += PROBE_BATCH_SIZE) {
|
|
283
|
-
await Promise.all(pending.slice(index, index + PROBE_BATCH_SIZE).map(async header => {
|
|
284
|
-
const meta = await resolveColdMeta(cache, persistence, header)
|
|
285
|
-
cacheSessionMeta(String(header.id), meta)
|
|
286
|
-
if (!meta.blank) {
|
|
287
|
-
summaries.push(toSummary(String(header.id), meta.title, header.cwd, header.createdAt, meta.promptAt, workspacePaths))
|
|
288
|
-
}
|
|
289
|
-
}))
|
|
186
|
+
// No title snapshot means the session never ran a turn: the host's
|
|
187
|
+
// deterministic fallback titles every session with a user message.
|
|
188
|
+
const meta = titleById.get(String(header.id))
|
|
189
|
+
if (meta === undefined) continue
|
|
190
|
+
summaries.push(toSummary(String(header.id), meta.title, header.cwd, header.createdAt, meta.promptAt, workspacePaths))
|
|
290
191
|
}
|
|
291
192
|
}
|
|
292
193
|
|
|
@@ -299,17 +200,6 @@ function fallbackName(id: string, directory?: string): string {
|
|
|
299
200
|
return match?.[0] ?? id
|
|
300
201
|
}
|
|
301
202
|
|
|
302
|
-
function titleFromEvents(events: readonly SessionEvent[]): string | undefined {
|
|
303
|
-
let title: string | undefined
|
|
304
|
-
for (const event of events) {
|
|
305
|
-
if ((event as { type?: string }).type === 'session/title') {
|
|
306
|
-
const data = (event as { data?: { title?: string } }).data
|
|
307
|
-
if (data?.title !== undefined && data.title !== '') title = data.title
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
return title
|
|
311
|
-
}
|
|
312
|
-
|
|
313
203
|
function firstUserText(events: readonly SessionEvent[]): string | undefined {
|
|
314
204
|
for (const event of events) {
|
|
315
205
|
if (event.type !== 'user/message') continue
|