decorated-pi 0.5.4 → 0.5.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/extensions/file-times.ts +4 -4
- package/extensions/io.ts +7 -1
- package/extensions/patch.ts +232 -53
- package/extensions/session-title.ts +37 -12
- package/package.json +1 -1
package/extensions/file-times.ts
CHANGED
|
@@ -53,16 +53,16 @@ export function checkStaleFile(absPath: string, displayPath: string): string | u
|
|
|
53
53
|
if (lastRead === undefined) {
|
|
54
54
|
// File exists but never read — must read first to avoid blind edits
|
|
55
55
|
return (
|
|
56
|
-
`
|
|
57
|
-
`
|
|
56
|
+
`Please read the file with the read tool before editing. ` +
|
|
57
|
+
`File not read yet: ${displayPath}.`
|
|
58
58
|
);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
const currentMtime = getFileMtime(absPath);
|
|
62
62
|
if (currentMtime > lastRead) {
|
|
63
63
|
return (
|
|
64
|
-
`
|
|
65
|
-
`
|
|
64
|
+
`Please re-read the file with the read tool before editing. ` +
|
|
65
|
+
`File modified since last read: ${displayPath}.`
|
|
66
66
|
);
|
|
67
67
|
}
|
|
68
68
|
|
package/extensions/io.ts
CHANGED
|
@@ -243,7 +243,13 @@ export function buildPatchCallComponent(component: PatchCallComponent, args: any
|
|
|
243
243
|
return component;
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
-
if (!body)
|
|
246
|
+
if (!body) {
|
|
247
|
+
// Patch succeeded but the diff is empty (e.g., old_str === new_str, or
|
|
248
|
+
// every hunk's reps were skipped). Tell the user nothing changed.
|
|
249
|
+
component.addChild(new Spacer(1));
|
|
250
|
+
component.addChild(new Text(theme.fg("dim", ` (no changes)`), 0, 0));
|
|
251
|
+
return component;
|
|
252
|
+
}
|
|
247
253
|
|
|
248
254
|
const lines = body.split("\n");
|
|
249
255
|
const FOLD_THRESHOLD = 45;
|
package/extensions/patch.ts
CHANGED
|
@@ -545,6 +545,11 @@ export async function computePatchPreview(
|
|
|
545
545
|
const lineOffsets = buildLineOffsets(rawContent);
|
|
546
546
|
const totalLines = lineOffsets.length - 1;
|
|
547
547
|
let content = normalizeLineEndings(rawContent);
|
|
548
|
+
// Snapshot the original (pre-edit) content for diff display. The
|
|
549
|
+
// `content` variable below is mutated in-place as edits are applied,
|
|
550
|
+
// but the TUI diff should show the ORIGINAL lines (not the post-edit
|
|
551
|
+
// content) so leading whitespace is preserved correctly.
|
|
552
|
+
const originalContent = content;
|
|
548
553
|
const allReplacements: ReplacementInfo[] = [];
|
|
549
554
|
const neededRanges: LineRange[] = [];
|
|
550
555
|
let cumulativeOffset = 0;
|
|
@@ -622,12 +627,13 @@ export async function computePatchPreview(
|
|
|
622
627
|
cumulativeOffset += newNorm.length - oldNorm.length;
|
|
623
628
|
}
|
|
624
629
|
|
|
625
|
-
// Merge needed ranges and extract
|
|
630
|
+
// Merge needed ranges and extract lines from the ORIGINAL content
|
|
631
|
+
// (not the mutated `content`), so the diff shows pre-edit lines.
|
|
626
632
|
const mergedRanges = mergeRanges(neededRanges);
|
|
627
|
-
const
|
|
633
|
+
const originalLineOffsets = buildLineOffsets(originalContent);
|
|
628
634
|
const neededLines: Map<number, string> = new Map();
|
|
629
635
|
for (const range of mergedRanges) {
|
|
630
|
-
const lines = extractLineRange(
|
|
636
|
+
const lines = extractLineRange(originalContent, originalLineOffsets, range.startLine, range.endLine);
|
|
631
637
|
for (let i = 0; i < lines.length; i++) {
|
|
632
638
|
neededLines.set(range.startLine + i, lines[i]);
|
|
633
639
|
}
|
|
@@ -761,6 +767,132 @@ function formatChunkMetadataLines(chunk: ReplacementChunk): string[] {
|
|
|
761
767
|
return lines;
|
|
762
768
|
}
|
|
763
769
|
|
|
770
|
+
type RenderOp =
|
|
771
|
+
| { type: "context"; line: number; text: string }
|
|
772
|
+
| { type: "removed"; line: number; text: string }
|
|
773
|
+
| { type: "added"; line: number; text: string };
|
|
774
|
+
|
|
775
|
+
interface RenderableReplacement {
|
|
776
|
+
operations: RenderOp[];
|
|
777
|
+
/** Line number of the first removed/added operation (BEFORE trimming).
|
|
778
|
+
* Used to limit how far "context before" extends. */
|
|
779
|
+
firstChangeLine: number;
|
|
780
|
+
/** Line number of the last removed/added operation (BEFORE trimming). */
|
|
781
|
+
lastChangeLine: number;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** Compute a minimal line-level diff between old and new lines using LCS.
|
|
785
|
+
* Common lines become context, while only truly different lines become
|
|
786
|
+
* removed/added. The resulting context is trimmed to `contextLines` lines
|
|
787
|
+
* before the first and after the last non-context operation so the TUI hunk
|
|
788
|
+
* doesn't grow with the size of the LLM's old_str. */
|
|
789
|
+
function splitReplacementForRender(
|
|
790
|
+
rep: ReplacementInfo,
|
|
791
|
+
contextLines: number,
|
|
792
|
+
): RenderableReplacement {
|
|
793
|
+
const m = rep.oldLines.length;
|
|
794
|
+
const n = rep.newLines.length;
|
|
795
|
+
|
|
796
|
+
// DP table: dp[i][j] = LCS length of oldLines[0..i) and newLines[0..j)
|
|
797
|
+
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array<number>(n + 1).fill(0));
|
|
798
|
+
for (let i = 1; i <= m; i++) {
|
|
799
|
+
for (let j = 1; j <= n; j++) {
|
|
800
|
+
if (rep.oldLines[i - 1] === rep.newLines[j - 1]) {
|
|
801
|
+
dp[i]![j] = dp[i - 1]![j - 1]! + 1;
|
|
802
|
+
} else {
|
|
803
|
+
dp[i]![j] = Math.max(dp[i - 1]![j]!, dp[i]![j - 1]!);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Backtrack to produce operations in reverse order.
|
|
809
|
+
const stack: RenderOp[] = [];
|
|
810
|
+
let i = m;
|
|
811
|
+
let j = n;
|
|
812
|
+
while (i > 0 && j > 0) {
|
|
813
|
+
if (rep.oldLines[i - 1] === rep.newLines[j - 1]) {
|
|
814
|
+
stack.push({ type: "context", line: rep.oldStartLine + i - 1, text: rep.oldLines[i - 1]! });
|
|
815
|
+
i--;
|
|
816
|
+
j--;
|
|
817
|
+
} else if (dp[i - 1]![j]! > dp[i]![j - 1]!) {
|
|
818
|
+
stack.push({ type: "removed", line: rep.oldStartLine + i - 1, text: rep.oldLines[i - 1]! });
|
|
819
|
+
i--;
|
|
820
|
+
} else {
|
|
821
|
+
stack.push({ type: "added", line: rep.oldStartLine + j - 1, text: rep.newLines[j - 1]! });
|
|
822
|
+
j--;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
while (i > 0) {
|
|
826
|
+
stack.push({ type: "removed", line: rep.oldStartLine + i - 1, text: rep.oldLines[i - 1]! });
|
|
827
|
+
i--;
|
|
828
|
+
}
|
|
829
|
+
while (j > 0) {
|
|
830
|
+
stack.push({ type: "added", line: rep.oldStartLine + j - 1, text: rep.newLines[j - 1]! });
|
|
831
|
+
j--;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// Reverse to get the final order.
|
|
835
|
+
const operations: RenderOp[] = [];
|
|
836
|
+
while (stack.length > 0) operations.push(stack.pop()!);
|
|
837
|
+
|
|
838
|
+
// Find first and last non-context operations (in the original order).
|
|
839
|
+
let firstChangeLine = rep.oldStartLine;
|
|
840
|
+
let lastChangeLine = rep.oldStartLine;
|
|
841
|
+
for (const op of operations) {
|
|
842
|
+
if (op.type !== "context") {
|
|
843
|
+
firstChangeLine = op.line;
|
|
844
|
+
break;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
for (let k = operations.length - 1; k >= 0; k--) {
|
|
848
|
+
if (operations[k]!.type !== "context") {
|
|
849
|
+
lastChangeLine = operations[k]!.line;
|
|
850
|
+
break;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Trim context operations that sit far from any non-context change.
|
|
855
|
+
const firstNonContextIdx = operations.findIndex(op => op.type !== "context");
|
|
856
|
+
if (firstNonContextIdx === -1) {
|
|
857
|
+
return { operations: [], firstChangeLine, lastChangeLine };
|
|
858
|
+
}
|
|
859
|
+
let lastNonContextIdx = operations.length - 1;
|
|
860
|
+
for (let k = operations.length - 1; k >= 0; k--) {
|
|
861
|
+
if (operations[k]!.type !== "context") { lastNonContextIdx = k; break; }
|
|
862
|
+
}
|
|
863
|
+
const start = Math.max(0, firstNonContextIdx - contextLines);
|
|
864
|
+
const end = Math.min(operations.length - 1, lastNonContextIdx + contextLines);
|
|
865
|
+
const trimmed = operations.slice(start, end + 1);
|
|
866
|
+
|
|
867
|
+
return { operations: trimmed, firstChangeLine, lastChangeLine };
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/** Compute the actual hunk range for a chunk by looking at the rendered
|
|
871
|
+
* context (before / after) and the LCS-trimmed operations. Returns
|
|
872
|
+
* [startLine, endLine] (1-based, inclusive). */
|
|
873
|
+
function computeRenderedRange(
|
|
874
|
+
chunk: ReplacementChunk,
|
|
875
|
+
repViews: Array<{ rep: ReplacementInfo; view: RenderableReplacement; beforeStart: number; afterEnd: number }>,
|
|
876
|
+
totalLines: number,
|
|
877
|
+
contextLines: number,
|
|
878
|
+
): { startLine: number; endLine: number } {
|
|
879
|
+
let renderedStart = Infinity;
|
|
880
|
+
let renderedEnd = -Infinity;
|
|
881
|
+
for (const { rep, view, beforeStart, afterEnd } of repViews) {
|
|
882
|
+
if (view.operations.length === 0 && beforeStart >= rep.oldStartLine && afterEnd <= rep.oldEndLine) continue;
|
|
883
|
+
const opStart = view.operations[0]!.line;
|
|
884
|
+
const opEnd = view.operations[view.operations.length - 1]!.line;
|
|
885
|
+
const s = Math.min(beforeStart, opStart);
|
|
886
|
+
const e = Math.max(afterEnd, opEnd);
|
|
887
|
+
if (s < renderedStart) renderedStart = s;
|
|
888
|
+
if (e > renderedEnd) renderedEnd = e;
|
|
889
|
+
}
|
|
890
|
+
if (renderedStart === Infinity) {
|
|
891
|
+
return { startLine: chunk.startLine, endLine: chunk.endLine };
|
|
892
|
+
}
|
|
893
|
+
return { startLine: renderedStart, endLine: Math.min(totalLines, renderedEnd) };
|
|
894
|
+
}
|
|
895
|
+
|
|
764
896
|
/**
|
|
765
897
|
* Generate diff as visual chunks merged by overlapping/adjacent context windows.
|
|
766
898
|
* This keeps spacing stable when multiple nearby edits would otherwise create
|
|
@@ -768,57 +900,72 @@ function formatChunkMetadataLines(chunk: ReplacementChunk): string[] {
|
|
|
768
900
|
*/
|
|
769
901
|
function generateReplacementDiff(filePath: string, reps: ReplacementInfo[], originalLines: string[]): string {
|
|
770
902
|
const parts: string[] = [];
|
|
771
|
-
parts.push(`--- ${filePath}`);
|
|
772
|
-
parts.push(`+++ ${filePath}`);
|
|
773
903
|
|
|
774
904
|
if (reps.length === 0) {
|
|
775
|
-
|
|
776
|
-
return parts.join("\n");
|
|
905
|
+
return "";
|
|
777
906
|
}
|
|
778
907
|
|
|
779
908
|
const maxLineNum = Math.max(originalLines.length, ...reps.map(r => r.oldEndLine));
|
|
780
909
|
const numWidth = String(maxLineNum).length;
|
|
781
910
|
const CONTEXT = 3;
|
|
782
911
|
const chunks = buildReplacementChunks(reps, originalLines.length, CONTEXT);
|
|
912
|
+
let firstHunk = true;
|
|
783
913
|
|
|
784
914
|
for (let c = 0; c < chunks.length; c++) {
|
|
785
915
|
const chunk = chunks[c]!;
|
|
786
|
-
if (c > 0) parts.push("");
|
|
787
|
-
parts.push(formatChunkHeader(chunk));
|
|
788
|
-
parts.push(...formatChunkMetadataLines(chunk));
|
|
789
916
|
|
|
790
|
-
|
|
917
|
+
// Pre-compute the rendered range for this chunk so the hunk header
|
|
918
|
+
// reflects what we actually emit (not the full chunk window).
|
|
919
|
+
const repViews = chunk.reps.map(rep => {
|
|
920
|
+
const v = splitReplacementForRender(rep, CONTEXT);
|
|
921
|
+
const beforeStart = Math.max(chunk.startLine, v.firstChangeLine - CONTEXT);
|
|
922
|
+
const afterEnd = Math.min(chunk.endLine, v.lastChangeLine + CONTEXT);
|
|
923
|
+
return { rep, view: v, beforeStart, afterEnd };
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
// Skip hunk entirely if every rep produced no effective changes
|
|
927
|
+
// (e.g., LLM sent old_str === new_str). Rendering context-only hunks
|
|
928
|
+
// is misleading — there is nothing to show.
|
|
929
|
+
if (repViews.every(r => r.view.operations.length === 0)) continue;
|
|
930
|
+
|
|
931
|
+
const { startLine: renderedStart, endLine: renderedEnd } = computeRenderedRange(
|
|
932
|
+
chunk, repViews, originalLines.length, CONTEXT,
|
|
933
|
+
);
|
|
934
|
+
const syntheticChunk = { ...chunk, startLine: renderedStart, endLine: renderedEnd };
|
|
935
|
+
parts.push(formatChunkHeader(syntheticChunk));
|
|
936
|
+
parts.push(...formatChunkMetadataLines(syntheticChunk));
|
|
791
937
|
|
|
792
|
-
for (const rep of
|
|
793
|
-
// Context before this
|
|
794
|
-
for (let i =
|
|
938
|
+
for (const { rep, view, beforeStart } of repViews) {
|
|
939
|
+
// Context before this rep: only lines within CONTEXT of the first change.
|
|
940
|
+
for (let i = beforeStart; i < rep.oldStartLine; i++) {
|
|
795
941
|
const num = String(i).padStart(numWidth, " ");
|
|
796
942
|
parts.push(` ${num} ${originalLines[i - 1]}`);
|
|
797
943
|
}
|
|
798
944
|
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
945
|
+
for (const op of view.operations) {
|
|
946
|
+
const num = String(op.line).padStart(numWidth, " ");
|
|
947
|
+
if (op.type === "context") parts.push(` ${num} ${op.text}`);
|
|
948
|
+
// For removed lines, use the file's actual content (not the LLM's
|
|
949
|
+
// old_str) so leading whitespace is preserved even if the LLM
|
|
950
|
+
// stripped it from the old_str.
|
|
951
|
+
else if (op.type === "removed") {
|
|
952
|
+
const fileLine = originalLines[op.line - 1] ?? op.text;
|
|
953
|
+
parts.push(`-${num} ${fileLine}`);
|
|
954
|
+
}
|
|
955
|
+
else parts.push(`+${num} ${op.text}`);
|
|
809
956
|
}
|
|
810
|
-
|
|
811
|
-
cursor = rep.oldEndLine + 1;
|
|
812
957
|
}
|
|
813
958
|
|
|
814
|
-
// Trailing context
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
959
|
+
// Trailing context after the last rep (already bounded by afterEnd).
|
|
960
|
+
const lastEntry = repViews[repViews.length - 1];
|
|
961
|
+
if (lastEntry) {
|
|
962
|
+
for (let i = lastEntry.rep.oldEndLine + 1; i <= lastEntry.afterEnd; i++) {
|
|
963
|
+
const num = String(i).padStart(numWidth, " ");
|
|
964
|
+
parts.push(` ${num} ${originalLines[i - 1]}`);
|
|
965
|
+
}
|
|
818
966
|
}
|
|
819
967
|
}
|
|
820
968
|
|
|
821
|
-
if (parts[parts.length - 1] !== "") parts.push("");
|
|
822
969
|
return parts.join("\n");
|
|
823
970
|
}
|
|
824
971
|
|
|
@@ -1028,8 +1175,7 @@ function generateLocalDiff(
|
|
|
1028
1175
|
if (reps.length === 0) return "";
|
|
1029
1176
|
|
|
1030
1177
|
const parts: string[] = [];
|
|
1031
|
-
|
|
1032
|
-
parts.push(`+++ ${filePath}`);
|
|
1178
|
+
let firstHunk = true;
|
|
1033
1179
|
|
|
1034
1180
|
// Calculate dynamic width based on max line number
|
|
1035
1181
|
const maxLineNum = Math.max(totalLines, ...reps.map(r => r.oldEndLine));
|
|
@@ -1038,36 +1184,69 @@ function generateLocalDiff(
|
|
|
1038
1184
|
// Merge replacement chunks
|
|
1039
1185
|
const chunks = buildReplacementChunks(reps, totalLines, CONTEXT_LINES);
|
|
1040
1186
|
for (let c = 0; c < chunks.length; c++) {
|
|
1041
|
-
if (c > 0) parts.push("");
|
|
1042
1187
|
const chunk = chunks[c]!;
|
|
1043
|
-
|
|
1044
|
-
|
|
1188
|
+
|
|
1189
|
+
// Pre-compute the rendered range so the hunk header reflects what we
|
|
1190
|
+
// actually emit (not the full chunk window).
|
|
1191
|
+
const repViews = chunk.reps.map(rep => {
|
|
1192
|
+
const view = splitReplacementForRender(rep, CONTEXT_LINES);
|
|
1193
|
+
const beforeStart = Math.max(chunk.startLine, view.firstChangeLine - CONTEXT_LINES);
|
|
1194
|
+
const afterEnd = Math.min(chunk.endLine, view.lastChangeLine + CONTEXT_LINES);
|
|
1195
|
+
return { rep, view, beforeStart, afterEnd };
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
// Skip hunk entirely if every rep produced no effective changes
|
|
1199
|
+
// (e.g., LLM sent old_str === new_str). Rendering context-only hunks
|
|
1200
|
+
// is misleading — there is nothing to show.
|
|
1201
|
+
if (repViews.every(r => r.view.operations.length === 0)) continue;
|
|
1202
|
+
|
|
1203
|
+
if (firstHunk) {
|
|
1204
|
+
parts.push(`--- ${filePath}`);
|
|
1205
|
+
parts.push(`+++ ${filePath}`);
|
|
1206
|
+
firstHunk = false;
|
|
1207
|
+
} else {
|
|
1208
|
+
parts.push("");
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
const { startLine: renderedStart, endLine: renderedEnd } = computeRenderedRange(
|
|
1212
|
+
chunk, repViews, totalLines, CONTEXT_LINES,
|
|
1213
|
+
);
|
|
1214
|
+
const syntheticChunk = { ...chunk, startLine: renderedStart, endLine: renderedEnd };
|
|
1215
|
+
parts.push(formatChunkHeader(syntheticChunk));
|
|
1216
|
+
parts.push(...formatChunkMetadataLines(syntheticChunk));
|
|
1045
1217
|
|
|
1046
1218
|
// Output context + removed + added
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
for (let i = cursor; i < rep.oldStartLine; i++) {
|
|
1219
|
+
for (const { rep, view, beforeStart } of repViews) {
|
|
1220
|
+
// Context before this replacement (only lines close to the first change)
|
|
1221
|
+
for (let i = beforeStart; i < rep.oldStartLine; i++) {
|
|
1051
1222
|
const lineText = neededLines.get(i);
|
|
1052
1223
|
if (lineText !== undefined) {
|
|
1053
1224
|
parts.push(` ${String(i).padStart(numWidth, " ")} ${lineText}`);
|
|
1054
1225
|
}
|
|
1055
1226
|
}
|
|
1056
|
-
|
|
1057
|
-
for (
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1227
|
+
|
|
1228
|
+
for (const op of view.operations) {
|
|
1229
|
+
const num = String(op.line).padStart(numWidth, " ");
|
|
1230
|
+
if (op.type === "context") parts.push(` ${num} ${op.text}`);
|
|
1231
|
+
// For removed lines, use the file's actual content (not the LLM's
|
|
1232
|
+
// old_str) so leading whitespace is preserved even if the LLM
|
|
1233
|
+
// stripped it from the old_str.
|
|
1234
|
+
else if (op.type === "removed") {
|
|
1235
|
+
const fileLine = neededLines.get(op.line) ?? op.text;
|
|
1236
|
+
parts.push(`-${num} ${fileLine}`);
|
|
1237
|
+
}
|
|
1238
|
+
else parts.push(`+${num} ${op.text}`);
|
|
1063
1239
|
}
|
|
1064
|
-
cursor = rep.oldEndLine + 1;
|
|
1065
1240
|
}
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1241
|
+
|
|
1242
|
+
// Trailing context after the last rep (already bounded by afterEnd).
|
|
1243
|
+
const lastEntry = repViews[repViews.length - 1];
|
|
1244
|
+
if (lastEntry) {
|
|
1245
|
+
for (let i = lastEntry.rep.oldEndLine + 1; i <= lastEntry.afterEnd; i++) {
|
|
1246
|
+
const lineText = neededLines.get(i);
|
|
1247
|
+
if (lineText !== undefined) {
|
|
1248
|
+
parts.push(` ${String(i).padStart(numWidth, " ")} ${lineText}`);
|
|
1249
|
+
}
|
|
1071
1250
|
}
|
|
1072
1251
|
}
|
|
1073
1252
|
}
|
|
@@ -9,25 +9,50 @@
|
|
|
9
9
|
|
|
10
10
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
/** Maximum length of the auto-derived session title. Single-line TUI footers
|
|
13
|
+
* break easily, so we cap in addition to newline truncation. */
|
|
14
|
+
export const MAX_SESSION_TITLE_LENGTH = 80;
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
16
|
+
interface SessionEntryLike {
|
|
17
|
+
type: string;
|
|
18
|
+
message?: { role: string; content?: unknown };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function pickFirstUserText(content: unknown): string | undefined {
|
|
22
|
+
if (typeof content === "string" && content.trim()) {
|
|
23
|
+
return content.trim();
|
|
24
|
+
}
|
|
25
|
+
if (Array.isArray(content)) {
|
|
26
|
+
for (const part of content) {
|
|
27
|
+
if (part && typeof part === "object" && part.type === "text" && typeof part.text === "string" && part.text.trim()) {
|
|
28
|
+
return part.text.trim();
|
|
25
29
|
}
|
|
26
30
|
}
|
|
27
31
|
}
|
|
28
32
|
return undefined;
|
|
29
33
|
}
|
|
30
34
|
|
|
35
|
+
/** Extract a single-line title from the first user message.
|
|
36
|
+
* Truncates at the first newline and caps length to keep the TUI footer safe. */
|
|
37
|
+
export function extractFirstMessage(entries: SessionEntryLike[]): string | undefined {
|
|
38
|
+
for (const entry of entries) {
|
|
39
|
+
if (entry.type !== "message" || !entry.message || entry.message.role !== "user") continue;
|
|
40
|
+
|
|
41
|
+
const raw = pickFirstUserText(entry.message.content);
|
|
42
|
+
if (!raw) continue;
|
|
43
|
+
|
|
44
|
+
// Truncate at first newline to avoid breaking TUI footer layout.
|
|
45
|
+
const nl = raw.indexOf("\n");
|
|
46
|
+
const oneLine = (nl === -1 ? raw : raw.slice(0, nl)).trim();
|
|
47
|
+
if (!oneLine) continue;
|
|
48
|
+
|
|
49
|
+
// Cap length to keep footer/terminal title rows short.
|
|
50
|
+
if (oneLine.length <= MAX_SESSION_TITLE_LENGTH) return oneLine;
|
|
51
|
+
return oneLine.slice(0, MAX_SESSION_TITLE_LENGTH - 1) + "…";
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
31
56
|
export function setupSessionTitle(pi: ExtensionAPI) {
|
|
32
57
|
pi.on("session_start", (_event, ctx) => {
|
|
33
58
|
if (ctx.sessionManager.getSessionName()) return;
|