linkshell-cli 0.2.86 → 0.2.88

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.
@@ -169,11 +169,55 @@ function toolInputFromItem(item: Record<string, unknown>): string | undefined {
169
169
  }
170
170
  if (itemType === "fileChange") {
171
171
  const changes = Array.isArray(item.changes) ? item.changes : [];
172
- return summarizeFileChanges(changes);
172
+ return summarizeFileChanges(changes) ?? firstString(item, ["path", "file", "filePath", "absolutePath", "relativePath"]);
173
173
  }
174
174
  return stringifyDefined(item.arguments ?? item.input ?? item.toolInput);
175
175
  }
176
176
 
177
+ function looksLikeDiff(text: string): boolean {
178
+ const value = text.trim();
179
+ return (
180
+ value.startsWith("diff --git ") ||
181
+ value.startsWith("@@ ") ||
182
+ value.includes("\n@@ ") ||
183
+ (value.includes("\n--- ") && value.includes("\n+++ "))
184
+ );
185
+ }
186
+
187
+ function collectDiffStrings(value: unknown, depth = 0): string[] {
188
+ if (depth > 6 || value === undefined || value === null) return [];
189
+ if (typeof value === "string") return looksLikeDiff(value) ? [value] : [];
190
+ if (Array.isArray(value)) return value.flatMap((entry) => collectDiffStrings(entry, depth + 1));
191
+ const raw = asRecord(value);
192
+ if (!raw) return [];
193
+ const direct: string[] = [];
194
+ const nested: string[] = [];
195
+ for (const [key, entry] of Object.entries(raw)) {
196
+ const lowerKey = key.toLowerCase();
197
+ const isDiffField =
198
+ lowerKey.includes("diff") ||
199
+ lowerKey.includes("patch") ||
200
+ lowerKey.includes("unified");
201
+ if (typeof entry === "string" && isDiffField && entry.trim()) {
202
+ direct.push(entry);
203
+ } else if (typeof entry === "object" && entry) {
204
+ nested.push(...collectDiffStrings(entry, depth + 1));
205
+ }
206
+ }
207
+ return [...direct, ...nested].filter((entry) => looksLikeDiff(entry));
208
+ }
209
+
210
+ function extractDiffText(value: unknown): string | undefined {
211
+ const diffs = collectDiffStrings(value)
212
+ .map((entry) => entry.trim())
213
+ .filter(Boolean);
214
+ if (diffs.length === 0) return undefined;
215
+ return diffs
216
+ .filter((entry, index, array) => array.indexOf(entry) === index)
217
+ .join("\n\n")
218
+ .slice(0, 24_000);
219
+ }
220
+
177
221
  function summarizeFileChanges(changes: unknown[]): string | undefined {
178
222
  const lines = changes
179
223
  .map((change) => {
@@ -183,7 +227,7 @@ function summarizeFileChanges(changes: unknown[]): string | undefined {
183
227
  firstString(raw, ["path", "file", "filePath", "absolutePath", "relativePath"]) ??
184
228
  firstString(asRecord(raw.update) ?? {}, ["path", "file", "filePath"]);
185
229
  const kind = firstString(raw, ["kind", "type", "operation", "action"]);
186
- return [kind, path].filter(Boolean).join(" ");
230
+ return [kind, path].filter(Boolean).join(" ") || path;
187
231
  })
188
232
  .filter((line): line is string => Boolean(line));
189
233
  return lines.length > 0 ? lines.slice(0, 8).join("\n") : undefined;
@@ -449,7 +493,6 @@ export class AgentSessionProxy {
449
493
  method.startsWith("mcpServer/startupStatus/") ||
450
494
  method === "thread/status/changed" ||
451
495
  method === "thread/tokenUsage/updated" ||
452
- method === "turn/diff/updated" ||
453
496
  method === "serverRequest/resolved" ||
454
497
  method === "mcpServer/oauthLogin/completed"
455
498
  ) {
@@ -502,6 +545,9 @@ export class AgentSessionProxy {
502
545
  case "item/fileChange/patchUpdated":
503
546
  this.handleFilePatchUpdated(params);
504
547
  return;
548
+ case "turn/diff/updated":
549
+ this.handleTurnDiffUpdated(params);
550
+ return;
505
551
  case "command/exec/outputDelta":
506
552
  this.handleCommandExecDelta(params);
507
553
  return;
@@ -700,17 +746,41 @@ export class AgentSessionProxy {
700
746
  if (!raw) return;
701
747
  const itemId = firstString(raw, ["itemId", "id"]) ?? id("file");
702
748
  const changes = Array.isArray(raw.changes) ? raw.changes : [];
703
- const output = summarizeFileChanges(changes);
749
+ const output = extractDiffText(raw) ?? summarizeFileChanges(changes);
704
750
  const existing = this.toolCalls.get(itemId);
705
- const toolCall: AgentToolCall = {
751
+ const toolCall = this.withToolCreatedAt({
706
752
  id: itemId,
707
753
  name: existing?.name ?? "文件修改",
708
754
  input: existing?.input,
709
755
  output: output || existing?.output,
710
756
  createdAt: existing?.createdAt ?? Date.now(),
711
757
  status: existing?.status ?? "running",
712
- };
713
- this.toolCalls.set(itemId, toolCall);
758
+ });
759
+ if (!toolCall) return;
760
+ if (toolCall.id !== itemId) this.toolCalls.delete(itemId);
761
+ this.toolCalls.set(toolCall.id, toolCall);
762
+ this.sendUpdate({ kind: "tool_call", toolCall, status: "running" });
763
+ }
764
+
765
+ private handleTurnDiffUpdated(params: unknown): void {
766
+ const raw = asRecord(params);
767
+ if (!raw) return;
768
+ const diff = extractDiffText(raw);
769
+ if (!diff) return;
770
+ const itemId = firstString(raw, ["itemId", "id", "turnId"]) ?? "workspace-diff";
771
+ const changes = Array.isArray(raw.changes) ? raw.changes : [];
772
+ const existing = this.toolCalls.get(itemId);
773
+ const toolCall = this.withToolCreatedAt({
774
+ id: itemId,
775
+ name: existing?.name ?? "文件修改",
776
+ input: existing?.input ?? summarizeFileChanges(changes),
777
+ output: diff,
778
+ createdAt: existing?.createdAt ?? Date.now(),
779
+ status: existing?.status ?? "running",
780
+ });
781
+ if (!toolCall) return;
782
+ if (toolCall.id !== itemId) this.toolCalls.delete(itemId);
783
+ this.toolCalls.set(toolCall.id, toolCall);
714
784
  this.sendUpdate({ kind: "tool_call", toolCall, status: "running" });
715
785
  }
716
786
 
@@ -766,15 +836,18 @@ export class AgentSessionProxy {
766
836
  const itemType = firstString(item, ["type"]);
767
837
  const name = toolNameFromItem(item);
768
838
  if (!name && !isToolItemType(itemType)) return undefined;
769
- const output =
839
+ const bufferedOutput = this.toolOutputBuffers.get(itemId);
840
+ const rawOutput =
770
841
  firstString(item, ["aggregatedOutput", "output", "stdout", "stderr"]) ??
771
842
  stringifyDefined(item.result ?? item.error ?? item.contentItems);
772
- const bufferedOutput = this.toolOutputBuffers.get(itemId);
843
+ const output = itemType === "fileChange"
844
+ ? extractDiffText(item) ?? bufferedOutput ?? rawOutput
845
+ : rawOutput ?? bufferedOutput;
773
846
  return {
774
847
  id: itemId,
775
848
  name: name ?? "工具",
776
849
  input: toolInputFromItem(item),
777
- output: output ?? bufferedOutput,
850
+ output,
778
851
  createdAt: Date.now(),
779
852
  status: normalizeToolStatus(item.status, fallbackStatus === "completed"),
780
853
  };
@@ -855,13 +928,26 @@ export class AgentSessionProxy {
855
928
 
856
929
  private withToolCreatedAt(toolCall: AgentToolCall | undefined): AgentToolCall | undefined {
857
930
  if (!toolCall) return undefined;
858
- const existing = this.toolCalls.get(toolCall.id);
931
+ const duplicate = this.findDuplicateFileTool(toolCall);
932
+ const existing = duplicate ?? this.toolCalls.get(toolCall.id);
859
933
  return {
934
+ ...existing,
860
935
  ...toolCall,
936
+ id: existing?.id ?? toolCall.id,
861
937
  createdAt: existing?.createdAt ?? toolCall.createdAt ?? Date.now(),
862
938
  };
863
939
  }
864
940
 
941
+ private findDuplicateFileTool(toolCall: AgentToolCall): AgentToolCall | undefined {
942
+ const output = toolCall.output?.trim();
943
+ if (!toolCall.name.includes("文件") || !output) return undefined;
944
+ return [...this.toolCalls.values()].find((existing) =>
945
+ existing.id !== toolCall.id &&
946
+ existing.name.includes("文件") &&
947
+ existing.output?.trim() === output
948
+ );
949
+ }
950
+
865
951
  private sendCapabilities(): void {
866
952
  const enabled = Boolean(this.client && this.initialized && !this.error);
867
953
  this.input.send(createEnvelope({
@@ -195,12 +195,56 @@ function summarizeFileChanges(changes: unknown[]): string | undefined {
195
195
  firstString(raw, ["path", "file", "filePath", "absolutePath", "relativePath"]) ??
196
196
  firstString(asRecord(raw.update), ["path", "file", "filePath"]);
197
197
  const kind = firstString(raw, ["kind", "type", "operation", "action"]);
198
- return [kind, path].filter(Boolean).join(" ");
198
+ return [kind, path].filter(Boolean).join(" ") || path;
199
199
  })
200
200
  .filter((line): line is string => Boolean(line));
201
201
  return lines.length > 0 ? lines.slice(0, 8).join("\n") : undefined;
202
202
  }
203
203
 
204
+ function looksLikeDiff(text: string): boolean {
205
+ const value = text.trim();
206
+ return (
207
+ value.startsWith("diff --git ") ||
208
+ value.startsWith("@@ ") ||
209
+ value.includes("\n@@ ") ||
210
+ (value.includes("\n--- ") && value.includes("\n+++ "))
211
+ );
212
+ }
213
+
214
+ function collectDiffStrings(value: unknown, depth = 0): string[] {
215
+ if (depth > 6 || value === undefined || value === null) return [];
216
+ if (typeof value === "string") return looksLikeDiff(value) ? [value] : [];
217
+ if (Array.isArray(value)) return value.flatMap((entry) => collectDiffStrings(entry, depth + 1));
218
+ const raw = asRecord(value);
219
+ if (!raw) return [];
220
+ const direct: string[] = [];
221
+ const nested: string[] = [];
222
+ for (const [key, entry] of Object.entries(raw)) {
223
+ const lowerKey = key.toLowerCase();
224
+ const isDiffField =
225
+ lowerKey.includes("diff") ||
226
+ lowerKey.includes("patch") ||
227
+ lowerKey.includes("unified");
228
+ if (typeof entry === "string" && isDiffField && entry.trim()) {
229
+ direct.push(entry);
230
+ } else if (typeof entry === "object" && entry) {
231
+ nested.push(...collectDiffStrings(entry, depth + 1));
232
+ }
233
+ }
234
+ return [...direct, ...nested].filter((entry) => looksLikeDiff(entry));
235
+ }
236
+
237
+ function extractDiffText(value: unknown): string | undefined {
238
+ const diffs = collectDiffStrings(value)
239
+ .map((entry) => entry.trim())
240
+ .filter(Boolean);
241
+ if (diffs.length === 0) return undefined;
242
+ return diffs
243
+ .filter((entry, index, array) => array.indexOf(entry) === index)
244
+ .join("\n\n")
245
+ .slice(0, 24_000);
246
+ }
247
+
204
248
  function toolInputFromItem(item: Record<string, unknown>): string | undefined {
205
249
  const itemType = firstString(item, ["type"]);
206
250
  if (itemType === "commandExecution") {
@@ -211,7 +255,7 @@ function toolInputFromItem(item: Record<string, unknown>): string | undefined {
211
255
  }
212
256
  if (itemType === "fileChange") {
213
257
  const changes = Array.isArray(item.changes) ? item.changes : [];
214
- return summarizeFileChanges(changes);
258
+ return summarizeFileChanges(changes) ?? firstString(item, ["path", "file", "filePath", "absolutePath", "relativePath"]);
215
259
  }
216
260
  return stringifyDefined(item.arguments ?? item.input ?? item.toolInput);
217
261
  }
@@ -645,7 +689,6 @@ export class AgentWorkspaceProxy {
645
689
  method.startsWith("mcpServer/startupStatus/") ||
646
690
  method === "thread/status/changed" ||
647
691
  method === "thread/tokenUsage/updated" ||
648
- method === "turn/diff/updated" ||
649
692
  method === "serverRequest/resolved" ||
650
693
  method === "mcpServer/oauthLogin/completed"
651
694
  ) {
@@ -705,6 +748,9 @@ export class AgentWorkspaceProxy {
705
748
  case "item/fileChange/patchUpdated":
706
749
  this.handleFilePatchUpdated(params);
707
750
  return;
751
+ case "turn/diff/updated":
752
+ this.handleTurnDiffUpdated(params);
753
+ return;
708
754
  case "command/exec/outputDelta":
709
755
  this.handleCommandExecDelta(params);
710
756
  return;
@@ -863,7 +909,9 @@ export class AgentWorkspaceProxy {
863
909
  this.toolConversationIds.get(itemId) ??
864
910
  this.activeConversationId;
865
911
  if (!conversationId) return;
866
- const output = summarizeFileChanges(Array.isArray(raw.changes) ? raw.changes : []);
912
+ const output =
913
+ extractDiffText(raw) ??
914
+ summarizeFileChanges(Array.isArray(raw.changes) ? raw.changes : []);
867
915
  const existing = this.findTool(conversationId, itemId);
868
916
  this.upsertTool(conversationId, {
869
917
  id: itemId,
@@ -875,6 +923,28 @@ export class AgentWorkspaceProxy {
875
923
  });
876
924
  }
877
925
 
926
+ private handleTurnDiffUpdated(params: unknown): void {
927
+ const raw = asRecord(params);
928
+ if (!raw) return;
929
+ const conversationId = this.conversationIdFromParams(raw) ?? this.activeConversationId;
930
+ if (!conversationId) return;
931
+ const diff = extractDiffText(raw);
932
+ if (!diff) return;
933
+ const itemId =
934
+ firstString(raw, ["itemId", "id", "turnId"]) ??
935
+ `workspace-diff:${conversationId}`;
936
+ const existing = this.findTool(conversationId, itemId);
937
+ const changes = Array.isArray(raw.changes) ? raw.changes : [];
938
+ this.upsertTool(conversationId, {
939
+ id: itemId,
940
+ name: existing?.name ?? "文件修改",
941
+ input: existing?.input ?? summarizeFileChanges(changes),
942
+ output: diff,
943
+ createdAt: existing?.createdAt ?? Date.now(),
944
+ status: existing?.status ?? "running",
945
+ });
946
+ }
947
+
878
948
  private handleCommandExecDelta(params: unknown): void {
879
949
  const raw = asRecord(params);
880
950
  if (!raw) return;
@@ -968,14 +1038,18 @@ export class AgentWorkspaceProxy {
968
1038
  const itemType = firstString(item, ["type"]);
969
1039
  const name = toolNameFromItem(item);
970
1040
  if (!name && !isToolItemType(itemType)) return undefined;
971
- const output =
1041
+ const bufferedOutput = this.toolOutputBuffers.get(itemId);
1042
+ const rawOutput =
972
1043
  firstString(item, ["aggregatedOutput", "output", "stdout", "stderr"]) ??
973
1044
  stringifyDefined(item.result ?? item.error ?? item.contentItems);
1045
+ const output = itemType === "fileChange"
1046
+ ? extractDiffText(item) ?? bufferedOutput ?? rawOutput
1047
+ : rawOutput ?? bufferedOutput;
974
1048
  return {
975
1049
  id: itemId,
976
1050
  name: name ?? "工具",
977
1051
  input: toolInputFromItem(item),
978
- output: output ?? this.toolOutputBuffers.get(itemId),
1052
+ output,
979
1053
  createdAt: Date.now(),
980
1054
  status: normalizeToolStatus(item.status, fallbackStatus === "completed"),
981
1055
  };
@@ -1085,11 +1159,19 @@ export class AgentWorkspaceProxy {
1085
1159
  }
1086
1160
 
1087
1161
  private upsertTool(conversationId: string, toolCall: AgentToolCall): void {
1088
- const existing = this.findTool(conversationId, toolCall.id);
1162
+ const duplicate = this.findDuplicateFileTool(conversationId, toolCall);
1163
+ if (duplicate && duplicate.id !== toolCall.id) {
1164
+ this.removeToolItem(conversationId, toolCall.id);
1165
+ }
1166
+ const targetToolId = duplicate?.id ?? toolCall.id;
1167
+ const existing = this.findTool(conversationId, targetToolId);
1089
1168
  const nextToolCall = {
1169
+ ...existing,
1090
1170
  ...toolCall,
1171
+ id: targetToolId,
1091
1172
  createdAt: existing?.createdAt ?? toolCall.createdAt ?? Date.now(),
1092
1173
  };
1174
+ this.toolConversationIds.set(toolCall.id, conversationId);
1093
1175
  this.toolConversationIds.set(nextToolCall.id, conversationId);
1094
1176
  this.upsertItem(conversationId, {
1095
1177
  id: `tool:${nextToolCall.id}`,
@@ -1101,6 +1183,27 @@ export class AgentWorkspaceProxy {
1101
1183
  });
1102
1184
  }
1103
1185
 
1186
+ private findDuplicateFileTool(
1187
+ conversationId: string,
1188
+ toolCall: AgentToolCall,
1189
+ ): AgentToolCall | undefined {
1190
+ const output = toolCall.output?.trim();
1191
+ if (!toolCall.name.includes("文件") || !output) return undefined;
1192
+ return this.timelines.get(conversationId)?.find((entry) =>
1193
+ entry.type === "tool_call" &&
1194
+ entry.toolCall?.id !== toolCall.id &&
1195
+ entry.toolCall?.name.includes("文件") &&
1196
+ entry.toolCall.output?.trim() === output
1197
+ )?.toolCall;
1198
+ }
1199
+
1200
+ private removeToolItem(conversationId: string, toolId: string): void {
1201
+ const timeline = this.timelines.get(conversationId);
1202
+ if (!timeline) return;
1203
+ const index = timeline.findIndex((entry) => entry.id === `tool:${toolId}`);
1204
+ if (index >= 0) timeline.splice(index, 1);
1205
+ }
1206
+
1104
1207
  private findItem(conversationId: string, itemId: string): AgentTimelineItem | undefined {
1105
1208
  return this.timelines.get(conversationId)?.find((item) => item.id === itemId);
1106
1209
  }