@trim21/personal-pi-extensions 0.0.359 → 0.0.360

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.359",
3
+ "version": "0.0.360",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -52,6 +52,15 @@ interface DiagnosticRequestResult {
52
52
  handled: boolean;
53
53
  matched: boolean;
54
54
  byFile: Map<string, Diagnostic[]>;
55
+ /** 单次请求是否超时(区别于正常失败:超时意味着服务器未响应,不应重试)。 */
56
+ timedOut: boolean;
57
+ }
58
+
59
+ /** 一批 pull 请求的聚合结果;timedOut 表示其中至少一个请求超时。 */
60
+ interface PullResult {
61
+ handled: boolean;
62
+ matched: boolean;
63
+ timedOut: boolean;
55
64
  }
56
65
 
57
66
  interface CapabilityRegistration {
@@ -350,9 +359,16 @@ export async function create(input: CreateInput): Promise<LspClient> {
350
359
 
351
360
  // ── 诊断拉取(pull)辅助 ────────────────────────────────────────────────────
352
361
 
353
- const mergeResults = (filePath: string, results: DiagnosticRequestResult[]) => {
354
- if (results.every((result) => !result.handled)) return { handled: false, matched: false };
362
+ const mergeResults = (filePath: string, results: DiagnosticRequestResult[]): PullResult => {
363
+ if (results.every((result) => !result.handled)) {
364
+ return {
365
+ handled: false,
366
+ matched: false,
367
+ timedOut: results.some((result) => result.timedOut),
368
+ };
369
+ }
355
370
  const matched = results.some((result) => result.matched);
371
+ const timedOut = results.some((result) => result.timedOut);
356
372
 
357
373
  const merged = new Map<string, Diagnostic[]>();
358
374
  for (const result of results) {
@@ -367,21 +383,27 @@ export async function create(input: CreateInput): Promise<LspClient> {
367
383
  updatePullDiagnostics(target, dedupeDiagnostics(items));
368
384
  }
369
385
 
370
- return { handled: true, matched };
386
+ return { handled: true, matched, timedOut };
371
387
  };
372
388
 
373
389
  async function requestDiagnosticReport(
374
390
  filePath: string,
375
391
  identifier?: string,
376
392
  ): Promise<DiagnosticRequestResult> {
393
+ let timedOut = false;
377
394
  const report = await withTimeout(
378
395
  connection.sendRequest<DocumentDiagnosticReport | null>("textDocument/diagnostic", {
379
396
  ...(identifier && { identifier }),
380
397
  textDocument: { uri: pathToFileURL(filePath).href },
381
398
  }),
382
399
  diagnosticsRequestTimeoutMs,
383
- ).catch(() => null);
384
- if (!report) return { handled: false, matched: false, byFile: new Map<string, Diagnostic[]>() };
400
+ ).catch((error: unknown) => {
401
+ if (error instanceof Error && error.message.startsWith("Timeout after")) timedOut = true;
402
+ return null;
403
+ });
404
+ if (!report) {
405
+ return { handled: false, matched: false, byFile: new Map<string, Diagnostic[]>(), timedOut };
406
+ }
385
407
 
386
408
  const byFile = new Map<string, Diagnostic[]>();
387
409
  const push = (target: string, items: Diagnostic[]): void => {
@@ -404,21 +426,27 @@ export async function create(input: CreateInput): Promise<LspClient> {
404
426
  matched ||= relatedPath === filePath;
405
427
  }
406
428
 
407
- return { handled, matched, byFile };
429
+ return { handled, matched, byFile, timedOut };
408
430
  }
409
431
 
410
432
  async function requestWorkspaceDiagnosticReport(
411
433
  filePath: string,
412
434
  identifier?: string,
413
435
  ): Promise<DiagnosticRequestResult> {
436
+ let timedOut = false;
414
437
  const report = await withTimeout(
415
438
  connection.sendRequest<WorkspaceDiagnosticReport | null>("workspace/diagnostic", {
416
439
  ...(identifier && { identifier }),
417
440
  previousResultIds: [],
418
441
  }),
419
442
  diagnosticsRequestTimeoutMs,
420
- ).catch(() => null);
421
- if (!report) return { handled: false, matched: false, byFile: new Map<string, Diagnostic[]>() };
443
+ ).catch((error: unknown) => {
444
+ if (error instanceof Error && error.message.startsWith("Timeout after")) timedOut = true;
445
+ return null;
446
+ });
447
+ if (!report) {
448
+ return { handled: false, matched: false, byFile: new Map<string, Diagnostic[]>(), timedOut };
449
+ }
422
450
 
423
451
  const byFile = new Map<string, Diagnostic[]>();
424
452
  let matched = false;
@@ -430,7 +458,7 @@ export async function create(input: CreateInput): Promise<LspClient> {
430
458
  matched ||= relatedPath === filePath;
431
459
  }
432
460
 
433
- return { handled: true, matched, byFile };
461
+ return { handled: true, matched, byFile, timedOut };
434
462
  }
435
463
 
436
464
  /** 是否支持文档级 pull 诊断:静态 diagnosticProvider 或动态注册的 document 诊断。 */
@@ -470,14 +498,14 @@ export async function create(input: CreateInput): Promise<LspClient> {
470
498
  filePath: string,
471
499
  requests: Promise<DiagnosticRequestResult>[],
472
500
  done: (results: DiagnosticRequestResult[]) => boolean,
473
- ): Promise<{ handled: boolean; matched: boolean }> {
474
- if (requests.length === 0) return { handled: false, matched: false };
501
+ ): Promise<PullResult> {
502
+ if (requests.length === 0) return { handled: false, matched: false, timedOut: false };
475
503
 
476
- return new Promise<{ handled: boolean; matched: boolean }>((resolve) => {
504
+ return new Promise<PullResult>((resolve) => {
477
505
  const results: DiagnosticRequestResult[] = [];
478
506
  let pending = requests.length;
479
507
  let resolved = false;
480
- const finish = (merged: { handled: boolean; matched: boolean }, force = false) => {
508
+ const finish = (merged: PullResult, force = false) => {
481
509
  if (resolved) return;
482
510
  if (!force && !done(results)) return;
483
511
  resolved = true;
@@ -505,9 +533,9 @@ export async function create(input: CreateInput): Promise<LspClient> {
505
533
 
506
534
  // 并发发起 identifier pull,一旦某批已产出当前文件诊断即可放行;
507
535
  // 慢的 pull 继续在后台合并,不按 identifier 串行。见 opencode PR #23771。
508
- async function requestDocumentDiagnostics(filePath: string) {
536
+ async function requestDocumentDiagnostics(filePath: string): Promise<PullResult> {
509
537
  const state = documentPullState();
510
- if (!state.supported) return { handled: false, matched: false };
538
+ if (!state.supported) return { handled: false, matched: false, timedOut: false };
511
539
  return requestDiagnostics(
512
540
  filePath,
513
541
  [
@@ -520,11 +548,11 @@ export async function create(input: CreateInput): Promise<LspClient> {
520
548
  );
521
549
  }
522
550
 
523
- async function requestFullDiagnostics(filePath: string) {
551
+ async function requestFullDiagnostics(filePath: string): Promise<PullResult> {
524
552
  const documentState = documentPullState();
525
553
  const workspaceState = workspacePullState();
526
554
  if (!documentState.supported && !workspaceState.supported) {
527
- return { handled: false, matched: false };
555
+ return { handled: false, matched: false, timedOut: false };
528
556
  }
529
557
  return mergeResults(
530
558
  filePath,
@@ -606,12 +634,13 @@ export async function create(input: CreateInput): Promise<LspClient> {
606
634
  signal?: AbortSignal;
607
635
  }): Promise<void> {
608
636
  const startedAt = request.after ?? Date.now();
609
- // 支持 pull 的服务器:push 已被忽略,pull 是唯一通道。无窗口截断——
610
- // 一直重试直到拿到当前文档结果(matched),确保诊断不因服务器重算慢而丢失。
637
+ // 支持 pull 的服务器:push 已被忽略,pull 是唯一通道。正常返回但未拿到
638
+ // 当前文档结果时重试;请求超时(服务器未响应)则中断,避免阻塞编辑。
611
639
  if (supportsPullDiagnostics()) {
612
640
  while (!connectionClosed && !request.signal?.aborted) {
613
641
  const result = await requestDocumentDiagnostics(request.path);
614
642
  if (result.matched) return;
643
+ if (result.timedOut) return;
615
644
  await sleep(PULL_RETRY_INTERVAL_MS);
616
645
  }
617
646
  return;
@@ -650,6 +679,7 @@ export async function create(input: CreateInput): Promise<LspClient> {
650
679
  while (!connectionClosed && !request.signal?.aborted) {
651
680
  const result = await requestFullDiagnostics(request.path);
652
681
  if (result.handled || result.matched) return;
682
+ if (result.timedOut) return;
653
683
  await sleep(PULL_RETRY_INTERVAL_MS);
654
684
  }
655
685
  return;
@@ -182,8 +182,13 @@ interface LspState {
182
182
  broken: Set<string>;
183
183
  spawning: Map<string, Promise<LspClient | undefined>>;
184
184
  closing: boolean;
185
+ /** root+serverID → 服务器状态,用于 footer status 显示。 */
186
+ servers: Map<string, { serverID: string; root: string; state: "running" | "broken" }>;
185
187
  }
186
188
 
189
+ /** 渲染 LSP status 文本的回调(传入 undefined 表示清除)。 */
190
+ export type StatusRenderer = (text: string | undefined) => void;
191
+
187
192
  export interface LspRequestOptions {
188
193
  notify?: ExtensionUIContext["notify"];
189
194
  /** 中止时提前结束诊断等待(已中止时直接跳过诊断)。 */
@@ -212,6 +217,10 @@ export interface LspService {
212
217
  diagnostics(): Promise<Record<string, Diagnostic[]>>;
213
218
  lspDiagnosticsForFile(file: string, cwd: string, options?: LspRequestOptions): Promise<string>;
214
219
  shutdownAll(): Promise<void>;
220
+ /** 注入 status 渲染回调;传入 undefined 表示不再渲染。 */
221
+ attachStatus(render: StatusRenderer | undefined): void;
222
+ /** 用当前服务器状态主动刷新一次 status(agent start/end 等生命周期边界)。 */
223
+ refreshStatus(): void;
215
224
  }
216
225
 
217
226
  /** 文件必须在工作目录内才启用 LSP(对齐 opencode 的 containsPath)。 */
@@ -241,8 +250,29 @@ export function createLspService(
241
250
  broken: new Set(),
242
251
  spawning: new Map(),
243
252
  closing: false,
253
+ servers: new Map(),
244
254
  };
245
255
 
256
+ let renderStatus: StatusRenderer | undefined;
257
+
258
+ /** 汇总当前所有 LSP server 状态并渲染到 footer status。 */
259
+ function updateStatusText(): void {
260
+ if (!renderStatus) return;
261
+ if (state.servers.size === 0) {
262
+ renderStatus(undefined);
263
+ return;
264
+ }
265
+ const parts = Array.from(state.servers.values(), (server) => {
266
+ return `${server.serverID}${server.state === "broken" ? " (unavailable)" : ""}`;
267
+ });
268
+ renderStatus(`lsp: ${parts.toSorted().join(",")}`);
269
+ }
270
+
271
+ function attachStatus(render: StatusRenderer | undefined): void {
272
+ renderStatus = render;
273
+ updateStatusText();
274
+ }
275
+
246
276
  async function getClients(
247
277
  file: string,
248
278
  cwd: string,
@@ -281,6 +311,8 @@ export function createLspService(
281
311
  const handle = await adapter.spawn(root, cwd);
282
312
  if (!handle) {
283
313
  state.broken.add(key);
314
+ state.servers.set(key, { serverID: adapter.id, root, state: "broken" });
315
+ updateStatusText();
284
316
  notify?.(
285
317
  `LSP server "${adapter.id}" is not available for ${root} (binary not found)`,
286
318
  "error",
@@ -307,9 +339,13 @@ export function createLspService(
307
339
  return duplicate;
308
340
  }
309
341
  state.clients.push(client);
342
+ state.servers.set(key, { serverID: adapter.id, root, state: "running" });
343
+ updateStatusText();
310
344
  return client;
311
345
  } catch (error) {
312
346
  state.broken.add(key);
347
+ state.servers.set(key, { serverID: adapter.id, root, state: "broken" });
348
+ updateStatusText();
313
349
  notify?.(
314
350
  `LSP server "${adapter.id}" failed to start for ${root}: ${
315
351
  error instanceof Error ? error.message : String(error)
@@ -399,9 +435,18 @@ export function createLspService(
399
435
  });
400
436
  state.clients = [];
401
437
  state.broken.clear();
438
+ state.servers.clear();
439
+ updateStatusText();
402
440
  }
403
441
 
404
- return { touchFile, diagnostics, lspDiagnosticsForFile, shutdownAll };
442
+ return {
443
+ touchFile,
444
+ diagnostics,
445
+ lspDiagnosticsForFile,
446
+ shutdownAll,
447
+ attachStatus,
448
+ refreshStatus: updateStatusText,
449
+ };
405
450
  }
406
451
 
407
452
  export interface LspServiceOptions {
@@ -420,6 +465,16 @@ export function registerLsp(pi: ExtensionAPI, options?: LspServiceOptions): LspS
420
465
  .catch((error: unknown) => {
421
466
  if (error instanceof Error) ctx.ui.notify?.(error.message, "error");
422
467
  });
468
+ // footer status 显示当前所有 LSP server 状态(无 UI 时不显示)
469
+ service.attachStatus(
470
+ ctx.ui?.setStatus
471
+ ? (text) => ctx.ui.setStatus("lsp", text ? ctx.ui.theme.fg("accent", text) : undefined)
472
+ : undefined,
473
+ );
423
474
  });
475
+ // agent 生命周期边界显式刷新 status:agent 运行中 LSP server 才被惰性
476
+ // spawn(首次工具调用),start/end 时保证 footer 反映当前实际状态。
477
+ pi.on?.("agent_start", () => service.refreshStatus());
478
+ pi.on?.("agent_end", () => service.refreshStatus());
424
479
  return service;
425
480
  }