jeo-code 0.7.16 → 0.7.18
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/CHANGELOG.md +34 -0
- package/README.ja.md +7 -7
- package/README.ko.md +7 -7
- package/README.md +32 -7
- package/README.zh.md +7 -7
- package/package.json +4 -2
- package/src/agent/AGENTS.md +2 -2
- package/src/agent/engine.ts +36 -13
- package/src/agent/goal-verifier.ts +6 -0
- package/src/agent/loop-guards.ts +24 -5
- package/src/agent/memory.ts +137 -63
- package/src/commands/deep-interview.ts +3 -1
- package/src/commands/launch.ts +163 -77
- package/src/commands/skills.ts +3 -12
- package/src/prompts/agents/architect.md +2 -0
- package/src/prompts/agents/critic.md +1 -0
- package/src/skills/catalog.ts +24 -1
- package/src/tui/components/autocomplete.ts +79 -3
- package/src/tui/components/live-model-picker.ts +40 -0
- package/src/tui/components/slash.ts +15 -3
- package/src/util/file-attachment.ts +58 -2
package/src/commands/skills.ts
CHANGED
|
@@ -1,18 +1,9 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import
|
|
4
|
-
import { SKILLS, getSkillFrom, formatSkill, loadSkills, skillDirs, bundledSkillFileContent } from "../skills/catalog";
|
|
3
|
+
import { SKILLS, getSkillFrom, formatSkill, loadSkills, skillDirs, bundledSkillFileContent, userSkillsDir } from "../skills/catalog";
|
|
5
4
|
import { getLocalJeoDir } from "../agent/state";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
/** User-level bundled-skills install dir (`~/.jeo/skills`, honoring JEO_CONFIG_DIR /
|
|
9
|
-
* $HOME overrides) — the destination `jeo skills sync` reconciles against, matching
|
|
10
|
-
* the highest-precedence flat dir in {@link skillDirs}. */
|
|
11
|
-
export function userSkillsDir(): string {
|
|
12
|
-
const userHome = process.env.HOME || os.homedir();
|
|
13
|
-
const home = jeoEnv("CONFIG_DIR") || path.join(userHome, ".jeo");
|
|
14
|
-
return path.join(home, "skills");
|
|
15
|
-
}
|
|
5
|
+
|
|
6
|
+
export { userSkillsDir };
|
|
16
7
|
|
|
17
8
|
export type SkillSyncStatus = "missing" | "up-to-date" | "differs";
|
|
18
9
|
export type SkillSyncAction = "installed" | "overwritten" | "preserved" | "unchanged" | "none";
|
|
@@ -11,6 +11,7 @@ Assess architecture, maintainability, correctness, and spec compliance with file
|
|
|
11
11
|
- Prioritize spec/root-cause correctness before style comments.
|
|
12
12
|
- Rate findings by severity: `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`.
|
|
13
13
|
- Never return `APPROVE` if CRITICAL/HIGH issues remain.
|
|
14
|
+
- A clean verdict is not the absence of inspection: do not return `CLEAR`/`APPROVE` merely because no problem surfaced. Base the verdict on files and paths you concretely examined, and say which ones.
|
|
14
15
|
</constraints>
|
|
15
16
|
|
|
16
17
|
<execution_loop>
|
|
@@ -29,6 +30,7 @@ Assess architecture, maintainability, correctness, and spec compliance with file
|
|
|
29
30
|
Your final `done.reason` MUST be markdown with these sections:
|
|
30
31
|
- `Summary:`
|
|
31
32
|
- `Findings:`
|
|
33
|
+
- `Inspected:` the files/paths you actually examined (evidence for the verdict)
|
|
32
34
|
- `Recommendations:`
|
|
33
35
|
- `Architectural Status:` one of `CLEAR`, `WATCH`, `BLOCK`
|
|
34
36
|
- `Code Review Recommendation:` one of `APPROVE`, `COMMENT`, `REQUEST CHANGES`
|
|
@@ -10,6 +10,7 @@ Decide whether a plan or approach is actionable before execution proceeds.
|
|
|
10
10
|
- Read-only: never modify files.
|
|
11
11
|
- Do not invent problems; reject only with concrete gaps.
|
|
12
12
|
- Simulate representative tasks against inspected evidence before deciding.
|
|
13
|
+
- Honesty cuts both ways: if you catch yourself softening a real, blocking gap into `[ITERATE]` just to avoid blocking, that softening is the signal the gap is real — name it. But never manufacture a block: when gaps are concrete yet fixable in-flight prefer `[ITERATE]` over `[REJECT]`, and return `[OKAY]` once the plan is genuinely actionable.
|
|
13
14
|
</constraints>
|
|
14
15
|
|
|
15
16
|
<execution_loop>
|
package/src/skills/catalog.ts
CHANGED
|
@@ -261,6 +261,15 @@ export function skillSlashAliases(skill: SkillDoc): string[] {
|
|
|
261
261
|
}
|
|
262
262
|
|
|
263
263
|
|
|
264
|
+
/** User-level bundled-skills install dir (`<config>/skills`, i.e. `~/.jeo/skills`
|
|
265
|
+
* honoring JEO_CONFIG_DIR / $HOME) — the highest-precedence flat skill dir and the
|
|
266
|
+
* destination `jeo skills sync` reconciles against. $HOME wins over os.homedir() so
|
|
267
|
+
* tests/sandboxes that re-point HOME are honored (see {@link skillDirs}). */
|
|
268
|
+
export function userSkillsDir(): string {
|
|
269
|
+
const userHome = process.env.HOME || os.homedir();
|
|
270
|
+
return path.join(jeoEnv("CONFIG_DIR") || path.join(userHome, ".jeo"), "skills");
|
|
271
|
+
}
|
|
272
|
+
|
|
264
273
|
/** Global + per-project skill-doc directories (user-configurable SKILL.md files).
|
|
265
274
|
* Ordered lowest → highest precedence (a later dir's skill overrides an earlier
|
|
266
275
|
* one with the same name): foreign-ecosystem roots first, jeo-native last.
|
|
@@ -283,7 +292,7 @@ export function skillDirs(cwd: string = process.cwd()): string[] {
|
|
|
283
292
|
path.join(userHome, ".claude", "skills"),
|
|
284
293
|
path.join(home, "agent", "skills"),
|
|
285
294
|
path.join(userHome, ".agents", "skills"),
|
|
286
|
-
|
|
295
|
+
userSkillsDir(),
|
|
287
296
|
path.join(cwd, ".claude", "skills"),
|
|
288
297
|
path.join(cwd, ".agents", "skills"),
|
|
289
298
|
path.join(cwd, ".jeo", "skills"),
|
|
@@ -512,6 +521,20 @@ export function getSkillBySlash(skills: SkillDoc[], command: string): SkillDoc |
|
|
|
512
521
|
return skills.find(s => skillSlashAliases(s).some(a => a.toLowerCase() === q));
|
|
513
522
|
}
|
|
514
523
|
|
|
524
|
+
/** Resolve a leading `/alias` slash command to the skill that DECLARES it, e.g.
|
|
525
|
+
* `/obsidian-capture note text` → { skill, intent: "note text", invokedAs: "/obsidian-capture" }.
|
|
526
|
+
* Returns null when the first token is not a declared skill slash alias. This is what lets an
|
|
527
|
+
* installed skill add real, dispatchable `/` commands (not just routing-hint metadata): only the
|
|
528
|
+
* FIRST token is matched, and the rest of the line becomes the intent passed to the skill. */
|
|
529
|
+
export function parseSkillSlashInvocation(input: string, skills: SkillDoc[]): SkillInvocation | null {
|
|
530
|
+
const trimmed = input.trim();
|
|
531
|
+
if (!trimmed.startsWith("/")) return null;
|
|
532
|
+
const command = trimmed.split(/\s+/, 1)[0] ?? "";
|
|
533
|
+
const skill = getSkillBySlash(skills, command);
|
|
534
|
+
if (!skill) return null;
|
|
535
|
+
return { skill, intent: trimmed.slice(command.length).trim(), invokedAs: command };
|
|
536
|
+
}
|
|
537
|
+
|
|
515
538
|
export interface SkillInvocation {
|
|
516
539
|
skill: SkillDoc;
|
|
517
540
|
intent: string;
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* completer never blocks on the network. Static data (slash names, catalog ids,
|
|
14
14
|
* provider names, role ids) is filled by `staticCompletionContext()`.
|
|
15
15
|
*/
|
|
16
|
-
import { SLASH_COMMANDS } from "./slash";
|
|
16
|
+
import { SLASH_COMMANDS, SLASH_COMMAND_DESCRIPTIONS } from "./slash";
|
|
17
17
|
import { catalogIds } from "../../ai/model-catalog-compat";
|
|
18
18
|
import { PROVIDER_NAMES } from "../../ai/provider-status";
|
|
19
19
|
import { SUBAGENT_ROLES } from "../../agent/subagents";
|
|
@@ -84,6 +84,82 @@ function prefixHits(pool: string[], token: string): string[] {
|
|
|
84
84
|
return pool.filter(c => c.toLowerCase().startsWith(q));
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Fuzzy subsequence test (gjc parity): every char of `query` appears in
|
|
89
|
+
* `target` in order (not necessarily adjacent). Empty query matches everything.
|
|
90
|
+
*/
|
|
91
|
+
export function fuzzyMatch(query: string, target: string): boolean {
|
|
92
|
+
if (!query) return true;
|
|
93
|
+
let qi = 0;
|
|
94
|
+
for (let ti = 0; ti < target.length && qi < query.length; ti++) {
|
|
95
|
+
if (target[ti] === query[qi]) qi++;
|
|
96
|
+
}
|
|
97
|
+
return qi === query.length;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Score a match (gjc parity): exact=100 > startsWith=80 > includes=60 >
|
|
102
|
+
* subsequence(40 − gaps×5, min 1). Returns 0 when `query` is not a subsequence.
|
|
103
|
+
*/
|
|
104
|
+
export function fuzzyScore(query: string, target: string): number {
|
|
105
|
+
if (!query) return 80;
|
|
106
|
+
if (query === target) return 100;
|
|
107
|
+
if (target.startsWith(query)) return 80;
|
|
108
|
+
if (target.includes(query)) return 60;
|
|
109
|
+
let qi = 0;
|
|
110
|
+
let gaps = 0;
|
|
111
|
+
let lastMatch = -1;
|
|
112
|
+
for (let ti = 0; ti < target.length && qi < query.length; ti++) {
|
|
113
|
+
if (target[ti] === query[qi]) {
|
|
114
|
+
if (lastMatch !== -1 && ti !== lastMatch + 1) gaps++;
|
|
115
|
+
lastMatch = ti;
|
|
116
|
+
qi++;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (qi < query.length) return 0;
|
|
120
|
+
return Math.max(1, 40 - gaps * 5);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Fuzzy command-name matches, ranked best-first (gjc §2.1): higher fuzzyScore
|
|
125
|
+
* first, ties broken by registration order so the list stays stable. Prefix
|
|
126
|
+
* matches always outrank looser subsequence hits, so `/mod`→`/model` is kept
|
|
127
|
+
* while `/mdl`→`/model` now also completes.
|
|
128
|
+
*/
|
|
129
|
+
function fuzzyHits(pool: string[], token: string): string[] {
|
|
130
|
+
const q = token.toLowerCase();
|
|
131
|
+
return pool
|
|
132
|
+
.map((c, index) => ({ c, score: fuzzyScore(q, c.toLowerCase()), index }))
|
|
133
|
+
.filter(x => x.score > 0)
|
|
134
|
+
.sort((a, b) => b.score - a.score || a.index - b.index)
|
|
135
|
+
.map(x => x.c);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Command-name completion with a description fallback (gjc §2.1). Name matches
|
|
140
|
+
* win outright (so `/mod`→`/model` stays precise); only when NOTHING matches the
|
|
141
|
+
* name do we fall back to substring-or-better description hits, so intent-style
|
|
142
|
+
* queries with no literal name match still resolve — e.g. `/oauth`→`/login`,
|
|
143
|
+
* `/transcript`→`/dump`. The description fallback requires ≥2 query chars and a
|
|
144
|
+
* real substring (not a loose subsequence) to avoid flooding the dropdown.
|
|
145
|
+
*/
|
|
146
|
+
function fuzzyCommandHits(pool: string[], token: string): string[] {
|
|
147
|
+
const nameHits = fuzzyHits(pool, token);
|
|
148
|
+
if (nameHits.length > 0) return nameHits;
|
|
149
|
+
|
|
150
|
+
const q = token.replace(/^\//, "").toLowerCase();
|
|
151
|
+
if (q.length < 2) return [];
|
|
152
|
+
return pool
|
|
153
|
+
.map((c, index) => {
|
|
154
|
+
const desc = SLASH_COMMAND_DESCRIPTIONS.get(c);
|
|
155
|
+
const hit = desc?.includes(q) ?? false;
|
|
156
|
+
return { c, score: hit ? fuzzyScore(q, desc!) * 0.5 : 0, index };
|
|
157
|
+
})
|
|
158
|
+
.filter(x => x.score > 0)
|
|
159
|
+
.sort((a, b) => b.score - a.score || a.index - b.index)
|
|
160
|
+
.map(x => x.c);
|
|
161
|
+
}
|
|
162
|
+
|
|
87
163
|
/** De-duplicate (case-insensitive, first wins), preserving order, capped. */
|
|
88
164
|
function dedupeCap(items: string[], cap = MAX_COMPLETIONS): string[] {
|
|
89
165
|
const seen = new Set<string>();
|
|
@@ -129,7 +205,7 @@ export function complete(line: string, ctx: CompletionContext): CompletionResult
|
|
|
129
205
|
// `/command` mention completion mid-line (the leading-token case is the
|
|
130
206
|
// dedicated command branch below, which also completes arguments).
|
|
131
207
|
if (token.startsWith("/")) {
|
|
132
|
-
return { completions: dedupeCap(
|
|
208
|
+
return { completions: dedupeCap(fuzzyCommandHits(ctx.slashCommands, token)), token, kind: "command" };
|
|
133
209
|
}
|
|
134
210
|
return { completions: [], token: line, kind: "none" };
|
|
135
211
|
}
|
|
@@ -137,7 +213,7 @@ export function complete(line: string, ctx: CompletionContext): CompletionResult
|
|
|
137
213
|
// Completing the command name itself (single token, still typing it).
|
|
138
214
|
if (tokens.length <= 1 && !trailingSpace) {
|
|
139
215
|
const token = tokens[0] ?? "/";
|
|
140
|
-
return { completions: dedupeCap(
|
|
216
|
+
return { completions: dedupeCap(fuzzyCommandHits(ctx.slashCommands, token)), token, kind: "command" };
|
|
141
217
|
}
|
|
142
218
|
|
|
143
219
|
const cmd = tokens[0]!.toLowerCase();
|
|
@@ -106,3 +106,43 @@ export function liveModelPicker(entries: PickEntry[], opts: LiveModelPickerOptio
|
|
|
106
106
|
export function renderLiveModelPicker(list: SelectList<PickEntry>, opts: RenderSelectOptions = {}): string[] {
|
|
107
107
|
return renderSelectList(list, { title: "Select a live model", rows: 12, ...opts });
|
|
108
108
|
}
|
|
109
|
+
|
|
110
|
+
/** The five reasoning levels in display order, lightest → heaviest. */
|
|
111
|
+
export const THINKING_LEVEL_ORDER = ["minimal", "low", "medium", "high", "xhigh"] as const;
|
|
112
|
+
|
|
113
|
+
const THINKING_LEVEL_DESCRIPTION: Record<string, string> = {
|
|
114
|
+
minimal: "lightest reasoning",
|
|
115
|
+
low: "light reasoning",
|
|
116
|
+
medium: "moderate reasoning",
|
|
117
|
+
high: "deep reasoning",
|
|
118
|
+
xhigh: "maximum reasoning",
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export interface ThinkingLevelChoiceOptions {
|
|
122
|
+
/** Prepend an "inherit" row (role targets only, which can follow the default). */
|
|
123
|
+
inheritLabel?: string;
|
|
124
|
+
/** Per-level reasoning-budget hint, e.g. "~32k tokens". Return undefined to omit. */
|
|
125
|
+
tokenHint?: (level: string) => string | undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* gajae-code-parity reasoning menu (gjc's `#renderThinkingMenu`): the per-target
|
|
130
|
+
* "Reasoning for …" choices shown after a model is picked. Each level renders as
|
|
131
|
+
* `<level> — <description> (<tokens>)` with the current level flagged. Role
|
|
132
|
+
* targets gain a leading `inherit` row. Pure — fully unit-testable.
|
|
133
|
+
*/
|
|
134
|
+
export function buildThinkingLevelChoices(
|
|
135
|
+
current: string | undefined,
|
|
136
|
+
opts: ThinkingLevelChoiceOptions = {},
|
|
137
|
+
): SelectItem<string>[] {
|
|
138
|
+
const items: SelectItem<string>[] = [];
|
|
139
|
+
if (opts.inheritLabel) {
|
|
140
|
+
items.push({ value: "inherit", label: opts.inheritLabel, hint: current === undefined ? "current" : "" });
|
|
141
|
+
}
|
|
142
|
+
for (const level of THINKING_LEVEL_ORDER) {
|
|
143
|
+
const tokens = opts.tokenHint?.(level);
|
|
144
|
+
const label = `${level} — ${THINKING_LEVEL_DESCRIPTION[level]}${tokens ? ` (${tokens})` : ""}`;
|
|
145
|
+
items.push({ value: level, label, hint: current === level ? "current" : "" });
|
|
146
|
+
}
|
|
147
|
+
return items;
|
|
148
|
+
}
|
|
@@ -65,6 +65,11 @@ export const SLASH_COMMAND_DETAILS: readonly SlashCommandInfo[] = [
|
|
|
65
65
|
|
|
66
66
|
export const SLASH_COMMANDS = SLASH_COMMAND_DETAILS.map(c => c.command);
|
|
67
67
|
|
|
68
|
+
/** Bundled command name → lowercased description (gjc §2.1 description matching). */
|
|
69
|
+
export const SLASH_COMMAND_DESCRIPTIONS = new Map<string, string>(
|
|
70
|
+
SLASH_COMMAND_DETAILS.map(c => [c.command, c.description.toLowerCase()]),
|
|
71
|
+
);
|
|
72
|
+
|
|
68
73
|
export function mergeSlashCommandDetails(extra: readonly SlashCommandInfo[] = []): SlashCommandInfo[] {
|
|
69
74
|
const byCommand = new Map<string, SlashCommandInfo>();
|
|
70
75
|
for (const d of [...SLASH_COMMAND_DETAILS, ...extra]) byCommand.set(d.command, d);
|
|
@@ -73,8 +78,10 @@ export function mergeSlashCommandDetails(extra: readonly SlashCommandInfo[] = []
|
|
|
73
78
|
|
|
74
79
|
/** Return the slash commands that match `input` (case-insensitive). Prefix matches
|
|
75
80
|
* come first; a fuzzy subsequence fallback (gjc-style, e.g. `/expt` → `/export`)
|
|
76
|
-
* follows so typos / abbreviations still surface a command.
|
|
77
|
-
*
|
|
81
|
+
* follows so typos / abbreviations still surface a command. When NOTHING matches
|
|
82
|
+
* the name, fall back to a description substring match (gjc §2.1) so intent-style
|
|
83
|
+
* queries with no literal name match still resolve — e.g. `/oauth` → `/login`,
|
|
84
|
+
* `/clipboard` → `/dump`. A bare `/` lists every command. Empty for non-slash input. */
|
|
78
85
|
export function matchSlash(input: string, commands: string[] = SLASH_COMMANDS): string[] {
|
|
79
86
|
if (!input.startsWith("/")) return [];
|
|
80
87
|
const q = input.toLowerCase();
|
|
@@ -82,7 +89,12 @@ export function matchSlash(input: string, commands: string[] = SLASH_COMMANDS):
|
|
|
82
89
|
if (body === "") return [...commands];
|
|
83
90
|
const starts = commands.filter(c => c.toLowerCase().startsWith(q));
|
|
84
91
|
const fuzzy = commands.filter(c => !starts.includes(c) && subsequence(body, c.slice(1).toLowerCase()));
|
|
85
|
-
|
|
92
|
+
const named = [...starts, ...fuzzy];
|
|
93
|
+
if (named.length > 0) return named;
|
|
94
|
+
// Description fallback: only when no name matches, and only for queries of ≥2
|
|
95
|
+
// chars with a real substring hit (not a loose subsequence), to avoid flooding.
|
|
96
|
+
if (body.length < 2) return [];
|
|
97
|
+
return commands.filter(c => SLASH_COMMAND_DESCRIPTIONS.get(c)?.includes(body) ?? false);
|
|
86
98
|
}
|
|
87
99
|
|
|
88
100
|
/** True when `input` looks like a slash command (starts with "/" and has no space). */
|
|
@@ -18,6 +18,54 @@ import type { ImageAttachment } from "../ai/types";
|
|
|
18
18
|
|
|
19
19
|
const IMAGE_EXT_RE = /\.(?:png|jpe?g|gif|webp|bmp)$/i;
|
|
20
20
|
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Normalize the whitespace around every `[image #N]` tag to a single space:
|
|
24
|
+
* - one space before a tag that follows other text (none at line start),
|
|
25
|
+
* - one space after a tag that is followed by more text,
|
|
26
|
+
* - any run of spaces/tabs collapsed to one, and the whole string trimmed.
|
|
27
|
+
*
|
|
28
|
+
* A tag at the very end keeps NO trailing space (callers that want the caret to sit
|
|
29
|
+
* past it add exactly one). Terminals pad a dragged-and-dropped path with their own
|
|
30
|
+
* spacing and the Ctrl+V insert adds a separator, so without this the caret parks
|
|
31
|
+
* several columns past the tag — the "input point looks pushed by a few spaces" bug.
|
|
32
|
+
* Idempotent: re-normalizing already-clean text is a no-op.
|
|
33
|
+
*/
|
|
34
|
+
export function normalizeImageTags(text: string): string {
|
|
35
|
+
return text
|
|
36
|
+
.replace(/[ \t]*(\[image #\d+\])[ \t]*/g, (m, tag: string, off: number, s: string) =>
|
|
37
|
+
(off > 0 ? " " : "") + tag + (off + m.length < s.length ? " " : ""),
|
|
38
|
+
)
|
|
39
|
+
.replace(/[ \t]{2,}/g, " ")
|
|
40
|
+
.trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Caret offset just past the `[image #n]` tag in `text` (and its single trailing
|
|
44
|
+
* space when present); falls back to end-of-text when the tag is absent. */
|
|
45
|
+
export function caretAfterTag(text: string, n: number): number {
|
|
46
|
+
const tag = `[image #${n}]`;
|
|
47
|
+
const idx = text.indexOf(tag);
|
|
48
|
+
if (idx < 0) return text.length;
|
|
49
|
+
let end = idx + tag.length;
|
|
50
|
+
if (text[end] === " ") end += 1; // sit after the separating space, not on it
|
|
51
|
+
return end;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Insert an `[image #n]` tag at `cursor` within `line` (the Ctrl+V clipboard-image
|
|
56
|
+
* path), normalizing surrounding whitespace so the tag is flanked by exactly one
|
|
57
|
+
* space. A tag landing at end-of-line gets one trailing space so the user can keep
|
|
58
|
+
* typing; the returned caret sits right after the tag (and that space).
|
|
59
|
+
*/
|
|
60
|
+
export function insertImageTag(line: string, cursor: number, n: number): { text: string; cursor: number } {
|
|
61
|
+
const at = Math.max(0, Math.min(cursor, line.length));
|
|
62
|
+
const tag = `[image #${n}]`;
|
|
63
|
+
let text = normalizeImageTags(line.slice(0, at) + tag + line.slice(at));
|
|
64
|
+
const idx = text.indexOf(tag);
|
|
65
|
+
if (idx >= 0 && idx + tag.length >= text.length) text += " "; // trailing space at EOL for typing
|
|
66
|
+
return { text, cursor: caretAfterTag(text, n) };
|
|
67
|
+
}
|
|
68
|
+
|
|
21
69
|
/** Detect an image media type from magic bytes, or null when the bytes are not a
|
|
22
70
|
* recognised image. Used as the authoritative check (extension only gates the
|
|
23
71
|
* candidate scan; the bytes decide). */
|
|
@@ -114,6 +162,10 @@ export interface AttachResult {
|
|
|
114
162
|
text: string;
|
|
115
163
|
/** The newly-read image attachments, in source order. */
|
|
116
164
|
images: ImageAttachment[];
|
|
165
|
+
/** Caret offset to park after the swap: just past the trailing space of the LAST
|
|
166
|
+
* inserted tag (so the live box places the cursor right after the attachment,
|
|
167
|
+
* ready for the user's prompt). Equals `text.length` when nothing was attached. */
|
|
168
|
+
cursor: number;
|
|
117
169
|
}
|
|
118
170
|
|
|
119
171
|
/**
|
|
@@ -131,7 +183,7 @@ export async function attachImagePaths(
|
|
|
131
183
|
read: FileReader = defaultReader,
|
|
132
184
|
): Promise<AttachResult> {
|
|
133
185
|
const tokens = findImagePathTokens(text);
|
|
134
|
-
if (tokens.length === 0) return { text, images: [] };
|
|
186
|
+
if (tokens.length === 0) return { text, images: [], cursor: text.length };
|
|
135
187
|
|
|
136
188
|
const reads = await Promise.all(
|
|
137
189
|
tokens.map(async (t) => {
|
|
@@ -154,5 +206,9 @@ export async function attachImagePaths(
|
|
|
154
206
|
n += 1;
|
|
155
207
|
}
|
|
156
208
|
out += text.slice(cursor);
|
|
157
|
-
return { text: out, images };
|
|
209
|
+
if (images.length === 0) return { text: out, images, cursor: out.length };
|
|
210
|
+
// Collapse the spacing terminals add around a dropped path so the swapped-in tag
|
|
211
|
+
// (and the caret parked after it) is not pushed several columns to the right.
|
|
212
|
+
const normalized = normalizeImageTags(out);
|
|
213
|
+
return { text: normalized, images, cursor: caretAfterTag(normalized, n - 1) };
|
|
158
214
|
}
|