pi-jar 0.1.2 → 0.1.4
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/README.md +46 -16
- package/docs/DEVELOPMENT.md +4 -2
- package/docs/WORKFLOWS.md +7 -3
- package/extensions/index.ts +194 -71
- package/package.json +1 -1
- package/src/activity-view.ts +525 -0
- package/src/advisor.ts +9 -3
- package/src/changes.ts +35 -11
- package/src/child-baselines.ts +42 -0
- package/src/compact-tools.ts +58 -24
- package/src/composer.ts +69 -21
- package/src/delegate.ts +492 -51
- package/src/dialogs.ts +9 -5
- package/src/diff-view.ts +13 -1
- package/src/flame.ts +18 -5
- package/src/footer.ts +131 -26
- package/src/history-ui.ts +19 -12
- package/src/history.ts +3 -1
- package/src/icons.ts +57 -0
- package/src/model-roles.ts +62 -7
- package/src/panel.ts +5 -1
- package/src/plan.ts +1 -0
- package/src/prompt-search.ts +20 -10
- package/src/row-cache.ts +26 -0
- package/src/session-gallery.ts +8 -3
- package/src/session-ui.ts +112 -42
- package/src/settings-ui.ts +10 -11
- package/src/settings.ts +5 -1
- package/src/shells.ts +8 -84
- package/src/side-model.ts +45 -25
- package/src/split-view.ts +29 -12
- package/src/status.ts +2 -0
- package/src/task-tool.ts +140 -53
- package/src/tasks-ui.ts +15 -5
- package/src/tasks.ts +224 -38
- package/src/welcome.ts +47 -14
- package/src/working.ts +3 -1
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, linkSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Directory a parent hands to a subagent so the child can report each file's content before its
|
|
7
|
+
* first edit/write. The parent then adopts those baselines into its own `/diff` review.
|
|
8
|
+
*/
|
|
9
|
+
export const CHILD_BASELINE_ENV = "PI_JAR_BASELINE_DIR";
|
|
10
|
+
|
|
11
|
+
type BaselineRecord = { path: string; content: string | null };
|
|
12
|
+
|
|
13
|
+
/** Writer and reader must agree on the name: one JSON file per absolute path. */
|
|
14
|
+
const fileFor = (dir: string, absPath: string) => join(dir, createHash("sha1").update(absPath).digest("hex") + ".json");
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Store the pre-change content (`null` = file did not exist) once per absolute path. The first
|
|
18
|
+
* write wins: the temp file is hard-linked into place, which fails instead of replacing an
|
|
19
|
+
* existing baseline, so readers never see a partial or newer record. Throws on I/O failure.
|
|
20
|
+
*/
|
|
21
|
+
export function writeChildBaseline(dir: string, absPath: string, content: string | null): void {
|
|
22
|
+
const target = fileFor(dir, absPath);
|
|
23
|
+
const temp = `${target}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
24
|
+
if (existsSync(target)) return;
|
|
25
|
+
mkdirSync(dir, { recursive: true });
|
|
26
|
+
try {
|
|
27
|
+
writeFileSync(temp, JSON.stringify({ path: absPath, content } satisfies BaselineRecord));
|
|
28
|
+
linkSync(temp, target);
|
|
29
|
+
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
|
|
30
|
+
finally { rmSync(temp, { force: true }); }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The recorded baseline, or `undefined` when absent, unreadable or malformed. */
|
|
34
|
+
export function readChildBaseline(dir: string, absPath: string): string | null | undefined {
|
|
35
|
+
let record: unknown;
|
|
36
|
+
try { record = JSON.parse(readFileSync(fileFor(dir, absPath), "utf8")); }
|
|
37
|
+
catch { return undefined; } // absent or corrupt: the parent simply does not adopt this file
|
|
38
|
+
if (!record || typeof record !== "object") return undefined;
|
|
39
|
+
const { path, content } = record as Partial<BaselineRecord>;
|
|
40
|
+
if (path !== absPath || (content !== null && typeof content !== "string")) return undefined;
|
|
41
|
+
return content;
|
|
42
|
+
}
|
package/src/compact-tools.ts
CHANGED
|
@@ -14,18 +14,31 @@ type ToolResult = { content: Array<{ type: string; text?: string }>; details?: u
|
|
|
14
14
|
|
|
15
15
|
const SUMMARY_SCAN_CHARS = 32 * 1024;
|
|
16
16
|
const DIFF_SCAN_CHARS = 64 * 1024;
|
|
17
|
-
|
|
17
|
+
interface Summary { sample: string; lines: number; truncated: boolean; preview?: string }
|
|
18
|
+
/**
|
|
19
|
+
* Pi hands renderResult a fresh `{ content, details }` wrapper on every display update, and Ctrl+O or a
|
|
20
|
+
* theme change re-renders every tool of the session, so these caches key on the stable content/details.
|
|
21
|
+
*/
|
|
22
|
+
const resultSummaryCache = new WeakMap<object, Summary>();
|
|
18
23
|
const writeCountCache = new WeakMap<object, { lines: number; truncated: boolean }>();
|
|
24
|
+
const diffCountCache = new WeakMap<object, { diff: string; additions: number; removals: number }>();
|
|
19
25
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
26
|
+
/** Line count without splitting the text into an array. */
|
|
27
|
+
function lineCount(text: string): number {
|
|
28
|
+
if (!text) return 0;
|
|
29
|
+
let lines = 1;
|
|
30
|
+
for (let at = text.indexOf("\n"); at >= 0; at = text.indexOf("\n", at + 1)) lines++;
|
|
31
|
+
return lines;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function summarizeResult(result: ToolResult): Summary {
|
|
35
|
+
const content = result.content;
|
|
36
|
+
const cached = resultSummaryCache.get(content);
|
|
37
|
+
if (cached) return cached;
|
|
25
38
|
let sample = "";
|
|
26
39
|
let truncated = false;
|
|
27
40
|
let textParts = 0;
|
|
28
|
-
for (const item of
|
|
41
|
+
for (const item of content) {
|
|
29
42
|
if (item.type !== "text" || typeof item.text !== "string") continue;
|
|
30
43
|
if (textParts++ && sample.length < SUMMARY_SCAN_CHARS) sample += "\n";
|
|
31
44
|
const room = SUMMARY_SCAN_CHARS - sample.length;
|
|
@@ -33,26 +46,41 @@ function summarizeResult(result: ToolResult): { sample: string; lines: number; t
|
|
|
33
46
|
sample += item.text.slice(0, room);
|
|
34
47
|
if (item.text.length > room) { truncated = true; break; }
|
|
35
48
|
}
|
|
36
|
-
const value = { sample, lines: sample
|
|
37
|
-
if (
|
|
49
|
+
const value: Summary = { sample, lines: lineCount(sample), truncated };
|
|
50
|
+
if (content && typeof content === "object") resultSummaryCache.set(content, value);
|
|
38
51
|
return value;
|
|
39
52
|
}
|
|
40
53
|
|
|
41
54
|
function firstNonEmpty(text: string): string {
|
|
42
|
-
for (
|
|
43
|
-
|
|
55
|
+
for (let start = 0; ;) {
|
|
56
|
+
const end = text.indexOf("\n", start);
|
|
57
|
+
const line = text.slice(start, end < 0 ? text.length : end);
|
|
58
|
+
if (line.trim()) return line;
|
|
59
|
+
if (end < 0) return "";
|
|
60
|
+
start = end + 1;
|
|
61
|
+
}
|
|
44
62
|
}
|
|
45
63
|
|
|
46
64
|
function boundedLineCount(text: string, limit = SUMMARY_SCAN_CHARS): { lines: number; truncated: boolean } {
|
|
47
|
-
|
|
48
|
-
return { lines: sample ? sample.split("\n").length : 0, truncated: text.length > limit };
|
|
65
|
+
return { lines: lineCount(text.slice(0, limit)), truncated: text.length > limit };
|
|
49
66
|
}
|
|
50
67
|
|
|
51
68
|
const expandHint = " · [ expand ] Ctrl+O";
|
|
52
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Whitespace-collapsed, trimmed text cut to `max` characters. Only a bounded prefix is scanned: a call
|
|
72
|
+
* card re-renders on every streamed argument delta, so collapsing a whole heredoc each time was O(n²).
|
|
73
|
+
*/
|
|
53
74
|
function compactText(text: string, max = 72): string {
|
|
54
|
-
|
|
55
|
-
|
|
75
|
+
for (let end = Math.min(text.length, 2 * max + 64); ; end = Math.min(text.length, end * 4)) {
|
|
76
|
+
const clean = text.slice(0, end).replace(/\s+/g, " ").trimStart();
|
|
77
|
+
if (end >= text.length) {
|
|
78
|
+
const full = clean.trimEnd();
|
|
79
|
+
return full.length <= max ? full : full.slice(0, Math.max(0, max - 1)) + "…";
|
|
80
|
+
}
|
|
81
|
+
// The collapsed prefix is a prefix of the whole; past max + 1 characters only one trailing space could still be trimmed.
|
|
82
|
+
if (clean.length > max + 1) return clean.slice(0, Math.max(0, max - 1)) + "…";
|
|
83
|
+
}
|
|
56
84
|
}
|
|
57
85
|
|
|
58
86
|
const bashCache = new WeakMap<object, { cwd: string; trusted: boolean; tool: ReturnType<typeof createBashToolDefinition> }>();
|
|
@@ -112,8 +140,11 @@ export function installCompactBuiltinTools(pi: ExtensionAPI): void {
|
|
|
112
140
|
renderResult(result, options, theme, context) {
|
|
113
141
|
if (options.expanded && bash.renderResult) return bash.renderResult(result as Parameters<NonNullable<typeof bash.renderResult>>[0], options, theme, context);
|
|
114
142
|
const summary = summarizeResult(result);
|
|
115
|
-
|
|
116
|
-
|
|
143
|
+
if (summary.preview === undefined) {
|
|
144
|
+
const first = firstNonEmpty(summary.sample);
|
|
145
|
+
summary.preview = first ? ` · ${compactText(first, 56)}` : "";
|
|
146
|
+
}
|
|
147
|
+
const preview = summary.preview;
|
|
117
148
|
const count = `${summary.lines}${summary.truncated ? "+" : ""}`;
|
|
118
149
|
const label = options.isPartial ? "running" : "done";
|
|
119
150
|
return new Text(theme.fg(options.isPartial ? "warning" : "muted",
|
|
@@ -138,14 +169,17 @@ export function installCompactBuiltinTools(pi: ExtensionAPI): void {
|
|
|
138
169
|
return new Text(theme.fg(text.startsWith("Error") ? "error" : "success", compactText(text || "applied", 96)), 0, 0);
|
|
139
170
|
}
|
|
140
171
|
if (diff.length > DIFF_SCAN_CHARS) return new Text(theme.fg("muted", `large diff${expandHint}`), 0, 0);
|
|
141
|
-
let
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
172
|
+
let counts = diffCountCache.get(details!);
|
|
173
|
+
if (counts?.diff !== diff) {
|
|
174
|
+
counts = { diff, additions: 0, removals: 0 };
|
|
175
|
+
for (const line of diff.split("\n")) {
|
|
176
|
+
if (line.startsWith("+") && !line.startsWith("+++")) counts.additions++;
|
|
177
|
+
else if (line.startsWith("-") && !line.startsWith("---")) counts.removals++;
|
|
178
|
+
}
|
|
179
|
+
diffCountCache.set(details!, counts);
|
|
146
180
|
}
|
|
147
|
-
return new Text(theme.fg("success", `+${additions}`) + theme.fg("dim", " / ")
|
|
148
|
-
+ theme.fg("error", `-${removals}`) + theme.fg("dim", expandHint), 0, 0);
|
|
181
|
+
return new Text(theme.fg("success", `+${counts.additions}`) + theme.fg("dim", " / ")
|
|
182
|
+
+ theme.fg("error", `-${counts.removals}`) + theme.fg("dim", expandHint), 0, 0);
|
|
149
183
|
}
|
|
150
184
|
});
|
|
151
185
|
|
package/src/composer.ts
CHANGED
|
@@ -4,6 +4,12 @@ import { Mascot, MASCOT_FACE_WIDTH, mascotFace, paintFace, paintTip, type Mascot
|
|
|
4
4
|
import type { SuggestionState } from "./suggest.ts";
|
|
5
5
|
import type { WorkingPhase } from "./working.ts";
|
|
6
6
|
import { imageChip, imageInfo, imagePaths } from "./attachments.ts";
|
|
7
|
+
import { RowCache } from "./row-cache.ts";
|
|
8
|
+
// Aliased: roundedInput has a parameter named `icon`.
|
|
9
|
+
import { icon as glyph, type IconKey } from "./icons.ts";
|
|
10
|
+
|
|
11
|
+
/** `⏎ send`; ascii spells the key out (`enter send`). */
|
|
12
|
+
const keyHint = (key: IconKey, action: string) => glyph(key) + " " + action;
|
|
7
13
|
|
|
8
14
|
type EditorFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>>;
|
|
9
15
|
type MouseEvent = Parameters<NonNullable<EditorComponent["handleMouse"]>>[0];
|
|
@@ -12,6 +18,8 @@ const PHASE_MOODS: Record<WorkingPhase, readonly MascotMood[]> = {
|
|
|
12
18
|
idle: ["idle"], generating: ["happy", "thinking"], tool: ["tool"], waiting: ["waiting"]
|
|
13
19
|
};
|
|
14
20
|
export const COMPOSER_ACTIVE_INTERVAL_MS = 600;
|
|
21
|
+
/** How long composer image chips trust a previous filesystem check for the same draft. */
|
|
22
|
+
const ATTACHMENT_RECHECK_MS = 1000;
|
|
15
23
|
/** Plain one-line face for a phase; every phase has the same width. */
|
|
16
24
|
export function composerIcon(phase: WorkingPhase, frame = 0): string {
|
|
17
25
|
const moods = PHASE_MOODS[phase];
|
|
@@ -70,12 +78,27 @@ export interface RoundedInputOptions {
|
|
|
70
78
|
dim?: (text: string) => string;
|
|
71
79
|
}
|
|
72
80
|
|
|
81
|
+
/** The last framed editor: Pi repaints the composer on every render (each streamed token) while the draft sits still. */
|
|
82
|
+
let lastFrame: { lines: string[]; key: string; border: (text: string) => string; dim?: (text: string) => string; framed: string[] } | undefined;
|
|
83
|
+
/** While typing only the edited row changes; the rest of a long draft is reused. */
|
|
84
|
+
const framedRows = new RowCache();
|
|
85
|
+
const fittedLines = new RowCache();
|
|
86
|
+
const sameLines = (a: readonly string[], b: readonly string[]) => {
|
|
87
|
+
if (a.length !== b.length) return false;
|
|
88
|
+
for (let index = 0; index < a.length; index++) if (a[index] !== b[index]) return false;
|
|
89
|
+
return true;
|
|
90
|
+
};
|
|
91
|
+
|
|
73
92
|
/** Frame a real editor without changing its keyboard, history or autocomplete implementation. */
|
|
74
93
|
export function roundedInput(lines: string[], width: number, focused: boolean, theme: EditorTheme, focusPaint?: (text: string) => string,
|
|
75
94
|
icon = composerIcon("idle"), session = "", options: RoundedInputOptions = {}): string[] {
|
|
76
95
|
if (width < 8 || lines.length < 2) return lines.map((line) => truncateToWidth(line, Math.max(0, width)));
|
|
77
96
|
const border = focused && focusPaint ? focusPaint : theme.borderColor;
|
|
78
97
|
const dim = options.dim ?? ((text: string) => text);
|
|
98
|
+
// Painter samples catch a theme change behind the same function.
|
|
99
|
+
const key = [width, icon, session, options.ghost ?? "", options.hint ?? "", options.paintedIcon ?? "", options.visibleRows ?? "",
|
|
100
|
+
options.below ?? "", glyph("tab"), border("─"), dim("─")].join("\0");
|
|
101
|
+
if (lastFrame?.key === key && lastFrame.border === border && lastFrame.dim === options.dim && sameLines(lastFrame.lines, lines)) return lastFrame.framed.slice();
|
|
79
102
|
const bottom = bottomBorderIndex(lines, options.visibleRows);
|
|
80
103
|
const inner = width - 2;
|
|
81
104
|
const meta = session ? `${icon} · session ${session}` : icon;
|
|
@@ -90,20 +113,26 @@ export function roundedInput(lines: string[], width: number, focused: boolean, t
|
|
|
90
113
|
const left = below ? `─ ${below} ` : "";
|
|
91
114
|
const fill = Math.max(0, width - 2 - visibleWidth(left) - visibleWidth(hint) - 1);
|
|
92
115
|
const bottomLine = border("╰" + left + "─".repeat(fill)) + (hint ? dim(hint) : "") + border((hint ? "─" : "─") + "╯");
|
|
116
|
+
const side = border("│");
|
|
117
|
+
framedRows.begin(`${inner}\0${side}`);
|
|
118
|
+
fittedLines.begin(String(width));
|
|
119
|
+
const fit = (text: string) => fittedLines.get(text, (value) => truncateToWidth(value, width));
|
|
93
120
|
const content = lines.slice(1, bottom).map((line, index) => {
|
|
94
121
|
let row = line;
|
|
95
122
|
if (index === 0 && options.ghost && row.includes(CURSOR)) {
|
|
96
123
|
const base = row.replace(/ +$/, "");
|
|
97
124
|
const room = inner - visibleWidth(base);
|
|
98
|
-
|
|
125
|
+
// ascii's tab "icon" is already the word.
|
|
126
|
+
const tab = glyph("tab") === "tab" ? "tab" : keyHint("tab", "tab");
|
|
127
|
+
if (room > 2) row = base + dim(truncateToWidth(options.ghost + " " + tab, room, "…"));
|
|
99
128
|
}
|
|
100
|
-
|
|
101
|
-
return border("│") + fitted + " ".repeat(Math.max(0, inner - visibleWidth(fitted))) + border("│");
|
|
129
|
+
return framedRows.get(row, (value) => side + truncateToWidth(value, inner, "...", true) + side);
|
|
102
130
|
});
|
|
103
131
|
// Autocomplete rows stay below the frame, indented to line up with the text.
|
|
104
|
-
const trailing = lines.slice(bottom + 1).map((line) =>
|
|
105
|
-
|
|
106
|
-
|
|
132
|
+
const trailing = lines.slice(bottom + 1).map((line) => fit(" " + line));
|
|
133
|
+
const framed = [fit(top), ...content, fit(bottomLine), ...(options.below ? [fit(" " + options.below)] : []), ...trailing];
|
|
134
|
+
lastFrame = { lines: lines.slice(), key, border, ...(options.dim ? { dim: options.dim } : {}), framed };
|
|
135
|
+
return framed.slice();
|
|
107
136
|
}
|
|
108
137
|
|
|
109
138
|
export interface ComposerDecor {
|
|
@@ -159,19 +188,25 @@ class RoundedEditor extends CustomEditor {
|
|
|
159
188
|
|
|
160
189
|
override render(width: number): string[] {
|
|
161
190
|
const inner = this.renderTall(width < 8 ? width : width - 2);
|
|
162
|
-
const
|
|
191
|
+
const text = this.getText();
|
|
192
|
+
const empty = text === "";
|
|
193
|
+
const below = this.decor.attachments(text);
|
|
163
194
|
const ghost = empty && !this.isShowingAutocomplete() ? this.decor.ghost() : undefined;
|
|
164
195
|
this.ghostShown = !!ghost;
|
|
165
196
|
const visibleRows = (this as unknown as { renderedVisibleLineCount?: number }).renderedVisibleLineCount;
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
197
|
+
const icon = this.decor.icon();
|
|
198
|
+
const session = this.decor.session();
|
|
199
|
+
const hint = this.decor.hint();
|
|
200
|
+
const paintedIcon = this.decor.paintedIcon();
|
|
201
|
+
const framed = roundedInput(inner, width, this.focused, this.colors, this.paint, icon, session, {
|
|
202
|
+
...(ghost ? { ghost } : {}), ...(hint ? { hint } : {}), ...(paintedIcon ? { paintedIcon } : {}),
|
|
169
203
|
...(visibleRows != null ? { visibleRows } : {}), dim: this.decor.dim,
|
|
170
|
-
...(
|
|
204
|
+
...(below ? { below } : {})
|
|
171
205
|
});
|
|
172
206
|
const tip = width >= 8 ? this.decor.tip(width) : undefined;
|
|
173
207
|
this.tipRows = tip ? 1 : 0;
|
|
174
|
-
|
|
208
|
+
// The tip is a few painted cells; measuring (cached) skips re-segmenting it on every frame.
|
|
209
|
+
return tip ? [visibleWidth(tip) <= width ? tip : truncateToWidth(tip, width), ...framed] : framed;
|
|
175
210
|
}
|
|
176
211
|
|
|
177
212
|
override handleInput(data: string): void {
|
|
@@ -230,14 +265,19 @@ class ThemedEditor implements EditorComponent {
|
|
|
230
265
|
}
|
|
231
266
|
invalidate() { this.base.invalidate(); }
|
|
232
267
|
render(width: number): string[] {
|
|
233
|
-
const
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
268
|
+
const text = this.base.getText();
|
|
269
|
+
const ghost = text === "" ? this.decor.ghost() : undefined;
|
|
270
|
+
const below = this.decor.attachments(text);
|
|
271
|
+
const icon = this.decor.icon();
|
|
272
|
+
const session = this.decor.session();
|
|
273
|
+
const paintedIcon = this.decor.paintedIcon();
|
|
274
|
+
const framed = roundedInput(this.base.render(width < 8 ? width : width - 2), width, this.focused, this.theme, this.paint, icon, session, {
|
|
275
|
+
...(ghost ? { ghost } : {}), ...(paintedIcon ? { paintedIcon } : {}), dim: this.decor.dim,
|
|
276
|
+
...(below ? { below } : {})
|
|
237
277
|
});
|
|
238
278
|
const tip = width >= 8 ? this.decor.tip(width) : undefined;
|
|
239
279
|
this.tipRows = tip ? 1 : 0;
|
|
240
|
-
return tip ? [truncateToWidth(tip, width), ...framed] : framed;
|
|
280
|
+
return tip ? [visibleWidth(tip) <= width ? tip : truncateToWidth(tip, width), ...framed] : framed;
|
|
241
281
|
}
|
|
242
282
|
addToHistory(text: string) { this.base.addToHistory?.(text); }
|
|
243
283
|
insertTextAtCursor(text: string) { this.base.insertTextAtCursor?.(text); }
|
|
@@ -269,6 +309,8 @@ export class ComposerStyle {
|
|
|
269
309
|
private suggestions?: SuggestionState;
|
|
270
310
|
private unsubscribe?: () => void;
|
|
271
311
|
private colors?: { fg(color: string, text: string): string };
|
|
312
|
+
/** Chips for the last draft; image paths hit the filesystem, so reuse them briefly across repaints. */
|
|
313
|
+
private chips?: { text: string; cwd: string; at: number; chips: string[] };
|
|
272
314
|
|
|
273
315
|
private readonly decor: ComposerDecor = {
|
|
274
316
|
icon: () => this.mascotOn ? mascotFace(this.animations ? this.mascot.mood() : "idle") : "pi",
|
|
@@ -286,7 +328,9 @@ export class ComposerStyle {
|
|
|
286
328
|
},
|
|
287
329
|
session: () => this.session,
|
|
288
330
|
ghost: () => this.suggestions?.text,
|
|
289
|
-
hint: () => this.suggestions?.text
|
|
331
|
+
hint: () => this.suggestions?.text
|
|
332
|
+
? `${keyHint("tab", "accept")} · ${keyHint("enter", "send")} · ${keyHint("shiftEnter", "newline")}`
|
|
333
|
+
: `${keyHint("enter", "send")} · ${keyHint("shiftEnter", "newline")} · /plan · /goal`,
|
|
290
334
|
accept: () => {
|
|
291
335
|
const text = this.suggestions?.text;
|
|
292
336
|
if (text) this.suggestions!.clear();
|
|
@@ -297,9 +341,13 @@ export class ComposerStyle {
|
|
|
297
341
|
dim: (text) => this.colors?.fg("dim", text) ?? `\x1b[2m${text}\x1b[22m`,
|
|
298
342
|
attachments: (text) => {
|
|
299
343
|
if (!/\.(png|jpe?g|gif|webp)\b/i.test(text)) return undefined;
|
|
300
|
-
const
|
|
301
|
-
if (
|
|
302
|
-
|
|
344
|
+
const now = Date.now();
|
|
345
|
+
if (this.chips?.text !== text || this.chips.cwd !== this.cwd || now - this.chips.at >= ATTACHMENT_RECHECK_MS) {
|
|
346
|
+
const chips = imagePaths(text, this.cwd).map(imageInfo).filter((info) => !!info).map((info) => imageChip(info!));
|
|
347
|
+
this.chips = { text, cwd: this.cwd, at: now, chips };
|
|
348
|
+
}
|
|
349
|
+
if (!this.chips.chips.length) return undefined;
|
|
350
|
+
return this.chips.chips.map((chip) => this.colors?.fg("accent", chip) ?? chip).join(this.colors?.fg("dim", " ") ?? " ");
|
|
303
351
|
}
|
|
304
352
|
};
|
|
305
353
|
|