jeo-code 0.5.1 → 0.5.3
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.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/commands/launch.ts +191 -22
- package/src/skills/catalog.ts +59 -1
package/README.ja.md
CHANGED
|
@@ -150,11 +150,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
150
150
|
## 変更履歴 (Changelog)
|
|
151
151
|
|
|
152
152
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
153
|
+
- **[0.5.3]** (2026-06-15) — `$` chains multiple skills in one line (all run, in order), plus multi-line prompt input — paste-merge and gated Shift+Enter.
|
|
154
|
+
- **[0.5.2]** (2026-06-14) — `$skill` prompt invocation with prefix/fuzzy suggestions, and a per-session input-box hue (amber in cmd-mode).
|
|
153
155
|
- **[0.5.1]** (2026-06-14) — cmd-mode `!<command>` shell escape — run a shell command without engaging the agent.
|
|
154
156
|
- **[0.5.0]** (2026-06-14) — Performance: workspace-scan, workflow-state, and DNA-Claw HUD caches; plus a credential-safety fix that never wipes OAuth over an invalid config.
|
|
155
157
|
- **[0.4.9]** (2026-06-14) — Live-frame width-clamp (content-sized height) replaces the constant-height approach, typed text shows during a running turn, and a docs/AGENTS refresh.
|
|
156
|
-
- **[0.4.8]** (2026-06-14) — Live-frame stability: constant-height live turn, renderer self-heal off-by-one fix, and frame-safe child-stdout sanitizing — no more duplicate model bar or torn escapes.
|
|
157
|
-
- **[0.4.7]** (2026-06-14) — Detached subagents + `subagent` control tool, live shaded in-flight output, registry-driven providers, fuller `read` budget, styled italics in the final report, and `gjc` retired.
|
|
158
158
|
|
|
159
159
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
160
160
|
<!-- CHANGELOG:END -->
|
package/README.ko.md
CHANGED
|
@@ -150,11 +150,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
150
150
|
## 변경 이력 (Changelog)
|
|
151
151
|
|
|
152
152
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
153
|
+
- **[0.5.3]** (2026-06-15) — `$` chains multiple skills in one line (all run, in order), plus multi-line prompt input — paste-merge and gated Shift+Enter.
|
|
154
|
+
- **[0.5.2]** (2026-06-14) — `$skill` prompt invocation with prefix/fuzzy suggestions, and a per-session input-box hue (amber in cmd-mode).
|
|
153
155
|
- **[0.5.1]** (2026-06-14) — cmd-mode `!<command>` shell escape — run a shell command without engaging the agent.
|
|
154
156
|
- **[0.5.0]** (2026-06-14) — Performance: workspace-scan, workflow-state, and DNA-Claw HUD caches; plus a credential-safety fix that never wipes OAuth over an invalid config.
|
|
155
157
|
- **[0.4.9]** (2026-06-14) — Live-frame width-clamp (content-sized height) replaces the constant-height approach, typed text shows during a running turn, and a docs/AGENTS refresh.
|
|
156
|
-
- **[0.4.8]** (2026-06-14) — Live-frame stability: constant-height live turn, renderer self-heal off-by-one fix, and frame-safe child-stdout sanitizing — no more duplicate model bar or torn escapes.
|
|
157
|
-
- **[0.4.7]** (2026-06-14) — Detached subagents + `subagent` control tool, live shaded in-flight output, registry-driven providers, fuller `read` budget, styled italics in the final report, and `gjc` retired.
|
|
158
158
|
|
|
159
159
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
160
160
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -150,11 +150,11 @@ Required npm token permissions (repository secret `NPM_TOKEN`):
|
|
|
150
150
|
## Changelog
|
|
151
151
|
|
|
152
152
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
153
|
+
- **[0.5.3]** (2026-06-15) — `$` chains multiple skills in one line (all run, in order), plus multi-line prompt input — paste-merge and gated Shift+Enter.
|
|
154
|
+
- **[0.5.2]** (2026-06-14) — `$skill` prompt invocation with prefix/fuzzy suggestions, and a per-session input-box hue (amber in cmd-mode).
|
|
153
155
|
- **[0.5.1]** (2026-06-14) — cmd-mode `!<command>` shell escape — run a shell command without engaging the agent.
|
|
154
156
|
- **[0.5.0]** (2026-06-14) — Performance: workspace-scan, workflow-state, and DNA-Claw HUD caches; plus a credential-safety fix that never wipes OAuth over an invalid config.
|
|
155
157
|
- **[0.4.9]** (2026-06-14) — Live-frame width-clamp (content-sized height) replaces the constant-height approach, typed text shows during a running turn, and a docs/AGENTS refresh.
|
|
156
|
-
- **[0.4.8]** (2026-06-14) — Live-frame stability: constant-height live turn, renderer self-heal off-by-one fix, and frame-safe child-stdout sanitizing — no more duplicate model bar or torn escapes.
|
|
157
|
-
- **[0.4.7]** (2026-06-14) — Detached subagents + `subagent` control tool, live shaded in-flight output, registry-driven providers, fuller `read` budget, styled italics in the final report, and `gjc` retired.
|
|
158
158
|
|
|
159
159
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
160
160
|
<!-- CHANGELOG:END -->
|
package/README.zh.md
CHANGED
|
@@ -150,11 +150,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
150
150
|
## 更新日志 (Changelog)
|
|
151
151
|
|
|
152
152
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
153
|
+
- **[0.5.3]** (2026-06-15) — `$` chains multiple skills in one line (all run, in order), plus multi-line prompt input — paste-merge and gated Shift+Enter.
|
|
154
|
+
- **[0.5.2]** (2026-06-14) — `$skill` prompt invocation with prefix/fuzzy suggestions, and a per-session input-box hue (amber in cmd-mode).
|
|
153
155
|
- **[0.5.1]** (2026-06-14) — cmd-mode `!<command>` shell escape — run a shell command without engaging the agent.
|
|
154
156
|
- **[0.5.0]** (2026-06-14) — Performance: workspace-scan, workflow-state, and DNA-Claw HUD caches; plus a credential-safety fix that never wipes OAuth over an invalid config.
|
|
155
157
|
- **[0.4.9]** (2026-06-14) — Live-frame width-clamp (content-sized height) replaces the constant-height approach, typed text shows during a running turn, and a docs/AGENTS refresh.
|
|
156
|
-
- **[0.4.8]** (2026-06-14) — Live-frame stability: constant-height live turn, renderer self-heal off-by-one fix, and frame-safe child-stdout sanitizing — no more duplicate model bar or torn escapes.
|
|
157
|
-
- **[0.4.7]** (2026-06-14) — Detached subagents + `subagent` control tool, live shaded in-flight output, registry-driven providers, fuller `read` budget, styled italics in the final report, and `gjc` retired.
|
|
158
158
|
|
|
159
159
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
160
160
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
package/src/commands/launch.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { emitKeypressEvents } from "node:readline";
|
|
3
|
+
import { PassThrough } from "node:stream";
|
|
2
4
|
import { runAgentLoop, executorSystemPrompt, DEFAULT_TOOLS, TOOL_PROTOCOL, WORKING_DISCIPLINE, type AgentLoopEvents } from "../agent/engine";
|
|
3
5
|
import { initialDynamicStepLimit } from "../agent/step-budget";
|
|
4
6
|
import { memoryPromptSection, spawnDetachedDistill } from "../agent/memory";
|
|
@@ -11,7 +13,7 @@ import { runDeepInterviewEngine } from "./deep-interview";
|
|
|
11
13
|
import { runRalplanEngine } from "./ralplan";
|
|
12
14
|
import { runTeamEngine } from "./team";
|
|
13
15
|
import { runUltragoalEngine } from "./ultragoal";
|
|
14
|
-
import { skillsPromptSection, loadSkills, formatSkill, buildSkillTask, getSkillFrom, skillSlashAliases, workflowSkillsForPrompt, parseSkillInvocation, looksLikeSkillEcho, skillInvocationCard, type SkillDoc } from "../skills/catalog";
|
|
16
|
+
import { skillsPromptSection, loadSkills, formatSkill, buildSkillTask, getSkillFrom, skillSlashAliases, workflowSkillsForPrompt, parseSkillInvocation, parseSkillChain, looksLikeSkillEcho, skillInvocationCard, type SkillDoc, type SkillInvocation } from "../skills/catalog";
|
|
15
17
|
import { formatForgeBox } from "../tui/components/forge";
|
|
16
18
|
import { interactiveOAuthLogin } from "./auth";
|
|
17
19
|
import { logoutOAuth } from "../auth";
|
|
@@ -1594,13 +1596,14 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1594
1596
|
return;
|
|
1595
1597
|
}
|
|
1596
1598
|
}
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1599
|
+
// One skill run (bundle workflow → engine; regular skill → agent turn). Shared by the
|
|
1600
|
+
// single-invocation path and the `$a $b …` chain path so every `$` skill actually runs.
|
|
1601
|
+
const runOneSkillShot = async (inv: SkillInvocation): Promise<void> => {
|
|
1602
|
+
const isBundleWorkflow = ["deep-interview", "ralplan", "team", "ultragoal"].includes(inv.skill.name);
|
|
1600
1603
|
if (isBundleWorkflow) {
|
|
1601
1604
|
const startMsg: Message = {
|
|
1602
1605
|
role: "system",
|
|
1603
|
-
content: `[workflow:${
|
|
1606
|
+
content: `[workflow:${inv.skill.name}:start]${inv.intent ? ` intent: ${inv.intent}` : ""}`
|
|
1604
1607
|
};
|
|
1605
1608
|
history.push(startMsg);
|
|
1606
1609
|
if (sessionId) {
|
|
@@ -1623,18 +1626,18 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1623
1626
|
console.log(line);
|
|
1624
1627
|
}
|
|
1625
1628
|
},
|
|
1626
|
-
args:
|
|
1629
|
+
args: inv.skill.name === "deep-interview" ? (inv.intent ? inv.intent.split(/\s+/) : []) : undefined
|
|
1627
1630
|
};
|
|
1628
1631
|
|
|
1629
1632
|
let ok = false;
|
|
1630
1633
|
let reason: string | undefined;
|
|
1631
1634
|
try {
|
|
1632
1635
|
let res: { ok: boolean; reason?: string };
|
|
1633
|
-
if (
|
|
1636
|
+
if (inv.skill.name === "deep-interview") {
|
|
1634
1637
|
res = await runDeepInterviewEngine(opts);
|
|
1635
|
-
} else if (
|
|
1638
|
+
} else if (inv.skill.name === "ralplan") {
|
|
1636
1639
|
res = await runRalplanEngine(opts);
|
|
1637
|
-
} else if (
|
|
1640
|
+
} else if (inv.skill.name === "team") {
|
|
1638
1641
|
res = await runTeamEngine(opts);
|
|
1639
1642
|
} else {
|
|
1640
1643
|
res = await runUltragoalEngine(opts);
|
|
@@ -1650,9 +1653,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1650
1653
|
|
|
1651
1654
|
const endMsg: Message = {
|
|
1652
1655
|
role: "system",
|
|
1653
|
-
content: ok
|
|
1654
|
-
? `[workflow:${
|
|
1655
|
-
: `[workflow:${
|
|
1656
|
+
content: ok
|
|
1657
|
+
? `[workflow:${inv.skill.name}:finish]`
|
|
1658
|
+
: `[workflow:${inv.skill.name}:abort]${reason ? ` reason: ${reason}` : ""}`
|
|
1656
1659
|
};
|
|
1657
1660
|
if (sessionId) {
|
|
1658
1661
|
await appendMessage(sessionId, endMsg, cwd);
|
|
@@ -1662,12 +1665,32 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1662
1665
|
|
|
1663
1666
|
const useOneShotTui = shouldUseOneShotTui(flags.noTui);
|
|
1664
1667
|
if (!useOneShotTui) {
|
|
1665
|
-
console.log(`▶ Running skill: ${
|
|
1668
|
+
console.log(`▶ Running skill: ${inv.skill.name}${inv.intent ? ` — ${inv.intent}` : ""}`);
|
|
1666
1669
|
}
|
|
1667
|
-
const task = buildSkillTask(
|
|
1670
|
+
const task = buildSkillTask(inv.skill, inv.intent, inv.invokedAs);
|
|
1668
1671
|
const { reply, rendered, usage } = await runTurn(task, useOneShotTui);
|
|
1669
1672
|
if (!rendered) console.log(stripMarkdown(renderMarkdownTables(reply)) + usage);
|
|
1670
1673
|
else if (usage) console.log(usage.trim());
|
|
1674
|
+
};
|
|
1675
|
+
|
|
1676
|
+
// `$a $b … [intent]` — run every resolved skill in order; a lone `$skill` is a chain of 1.
|
|
1677
|
+
const skillChain = parseSkillChain(messageContent, resolvedSkills);
|
|
1678
|
+
if (skillChain && skillChain.invocations.length) {
|
|
1679
|
+
if (skillChain.unresolved.length) {
|
|
1680
|
+
console.log(`(skipping unknown skill${skillChain.unresolved.length > 1 ? "s" : ""}: ${skillChain.unresolved.map(u => `$${u}`).join(", ")})`);
|
|
1681
|
+
}
|
|
1682
|
+
if (skillChain.invocations.length > 1) {
|
|
1683
|
+
console.log(`▶ Chaining ${skillChain.invocations.length} skills: ${skillChain.invocations.map(i => `$${i.skill.name}`).join(" → ")}`);
|
|
1684
|
+
}
|
|
1685
|
+
for (const inv of skillChain.invocations) {
|
|
1686
|
+
await runOneSkillShot(inv);
|
|
1687
|
+
}
|
|
1688
|
+
return;
|
|
1689
|
+
}
|
|
1690
|
+
// Slash / file-path / `/skill:` entrypoints (non-`$`) still resolve to a single skill.
|
|
1691
|
+
const skillInvocation = parseSkillInvocation(messageContent, resolvedSkills);
|
|
1692
|
+
if (skillInvocation) {
|
|
1693
|
+
await runOneSkillShot(skillInvocation);
|
|
1671
1694
|
return;
|
|
1672
1695
|
}
|
|
1673
1696
|
try {
|
|
@@ -1891,8 +1914,50 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1891
1914
|
// scrollback before the boxed footer takes over.
|
|
1892
1915
|
let promptActive = false;
|
|
1893
1916
|
let pickerActive = false;
|
|
1917
|
+
// ── Multi-line input (JEO_MULTILINE=1, gated) ──────────────────────────────────
|
|
1918
|
+
// Shift+Enter inserts a newline instead of submitting. ghostty (and most terminals)
|
|
1919
|
+
// send Shift+Enter as a distinct escape sequence (\x1b[27;2;13~ legacy, \x1b[13;2u
|
|
1920
|
+
// kitty) that node:readline otherwise mangles into garbage. We route stdin through a
|
|
1921
|
+
// filter that rewrites those sequences to a private-use SENTINEL char BEFORE readline
|
|
1922
|
+
// sees them — readline inserts it as an ordinary character (no submit), and we render
|
|
1923
|
+
// it as a real line break and expand it to "\n" on submit. Default OFF: when disabled
|
|
1924
|
+
// the prompt reads process.stdin directly, exactly as before (zero risk).
|
|
1925
|
+
const SENTINEL = "\uE000";
|
|
1926
|
+
const SHIFT_ENTER_SEQS = ["\u001b[27;2;13~", "\u001b[13;2u"];
|
|
1927
|
+
const multilineInput = jeoEnv("MULTILINE") === "1" && !!process.stdin.isTTY;
|
|
1928
|
+
const expandSentinel = (s: string): string => (multilineInput ? s.split(SENTINEL).join("\n") : s);
|
|
1929
|
+
let keyFilter: PassThrough | undefined;
|
|
1930
|
+
if (multilineInput) {
|
|
1931
|
+
const kf = new PassThrough();
|
|
1932
|
+
(kf as unknown as { isTTY: boolean }).isTTY = true;
|
|
1933
|
+
(kf as unknown as { setRawMode: (m: boolean) => unknown }).setRawMode = (m: boolean) => {
|
|
1934
|
+
try { (process.stdin as { setRawMode?(r: boolean): void }).setRawMode?.(m); } catch { /* terminal gone */ }
|
|
1935
|
+
return kf;
|
|
1936
|
+
};
|
|
1937
|
+
Object.defineProperty(kf, "isRaw", { get: () => (process.stdin as { isRaw?: boolean }).isRaw });
|
|
1938
|
+
// Forward stdin → filter, rewriting Shift+Enter into the newline sentinel BEFORE
|
|
1939
|
+
// readline sees it. Three encodings are handled so it works across setups:
|
|
1940
|
+
// • a lone "\n" (0x0a) chunk — what ghostty's `keybind = shift+enter=text:\n`
|
|
1941
|
+
// sends; a normal byte that passes through tmux UNCHANGED (works even with
|
|
1942
|
+
// tmux extended-keys off). Enter sends "\r" in raw mode, so a lone "\n" is
|
|
1943
|
+
// unambiguously Shift+Enter. This is the reliable ghostty+tmux path.
|
|
1944
|
+
// • the xterm legacy "\x1b[27;2;13~" and kitty "\x1b[13;2u" sequences — direct
|
|
1945
|
+
// terminal (no tmux) or tmux with extended-keys on. (Sent atomically, so a
|
|
1946
|
+
// per-chunk replace suffices; no partial buffering that could swallow ESC.)
|
|
1947
|
+
process.stdin.on("data", (chunk: Buffer) => {
|
|
1948
|
+
let data = chunk.toString("utf8");
|
|
1949
|
+
if (data === "\n") data = SENTINEL;
|
|
1950
|
+
else for (const seq of SHIFT_ENTER_SEQS) if (data.includes(seq)) data = data.split(seq).join(SENTINEL);
|
|
1951
|
+
kf.write(data);
|
|
1952
|
+
});
|
|
1953
|
+
keyFilter = kf;
|
|
1954
|
+
// readline now decodes keypresses on `keyFilter`; keep process.stdin emitting
|
|
1955
|
+
// 'keypress' too so the footer-redraw / paste-marker / picker listeners (registered
|
|
1956
|
+
// on process.stdin below) still fire.
|
|
1957
|
+
emitKeypressEvents(process.stdin);
|
|
1958
|
+
}
|
|
1894
1959
|
const rl = createInterface({
|
|
1895
|
-
input: process.stdin,
|
|
1960
|
+
input: keyFilter ?? process.stdin,
|
|
1896
1961
|
// Single-box input: gate readline's output while the boxed footer is armed so its own
|
|
1897
1962
|
// `jeo>` prompt/echo is suppressed and ONLY our box shows. (Bun exposes no
|
|
1898
1963
|
// `_writeToOutput` to patch, so gating the shared output stream is the portable fix.)
|
|
@@ -1937,10 +2002,19 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1937
2002
|
// outside it keep the existing contracts (typed-line prefill fold on a TTY,
|
|
1938
2003
|
// in-order auto-serve for piped stdin).
|
|
1939
2004
|
let promptPasteActive = false;
|
|
2005
|
+
// PASTE-MERGE: a multi-line bracketed paste must arrive as ONE message, not split into
|
|
2006
|
+
// one command per line. Lines that fire WHILE a paste is open are buffered here instead
|
|
2007
|
+
// of run individually; promptInput joins them with the trailing residual on resolve.
|
|
2008
|
+
// `endWaiters` wake that merge the moment the 201~ paste-end marker arrives.
|
|
2009
|
+
const pasteMerge: { buf: string[]; endWaiters: Array<() => void> } = { buf: [], endWaiters: [] };
|
|
2010
|
+
let pasteLineFired = false; // the line that resolved rl.question came from inside a paste
|
|
1940
2011
|
if (process.stdin.isTTY) {
|
|
1941
2012
|
process.stdin.on("keypress", (_ch: string, key: { name?: string } | undefined) => {
|
|
1942
|
-
if (key?.name === "paste-start") promptPasteActive = true;
|
|
1943
|
-
else if (key?.name === "paste-end")
|
|
2013
|
+
if (key?.name === "paste-start") { promptPasteActive = true; pasteMerge.buf = []; }
|
|
2014
|
+
else if (key?.name === "paste-end") {
|
|
2015
|
+
promptPasteActive = false;
|
|
2016
|
+
for (const w of pasteMerge.endWaiters.splice(0)) w();
|
|
2017
|
+
}
|
|
1944
2018
|
});
|
|
1945
2019
|
// Enable bracketed paste for the REPL lifetime (restored on exit below):
|
|
1946
2020
|
// terminals only wrap pastes in the 200~/201~ markers once the app opts in.
|
|
@@ -1949,7 +2023,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1949
2023
|
}
|
|
1950
2024
|
rl.on("line", l => {
|
|
1951
2025
|
if (promptPasteActive) {
|
|
1952
|
-
|
|
2026
|
+
// Inside a bracketed paste: buffer the line, never run it on its own.
|
|
2027
|
+
pasteMerge.buf.push(l);
|
|
2028
|
+
pasteLineFired = true;
|
|
1953
2029
|
return;
|
|
1954
2030
|
}
|
|
1955
2031
|
if (!interactiveTurnActive) pendingStdinLines.push(l);
|
|
@@ -1989,8 +2065,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1989
2065
|
return "/exit";
|
|
1990
2066
|
}
|
|
1991
2067
|
promptActive = true;
|
|
2068
|
+
pasteLineFired = false;
|
|
1992
2069
|
try {
|
|
1993
|
-
|
|
2070
|
+
const value = await Promise.race([
|
|
1994
2071
|
rl.question(prompt),
|
|
1995
2072
|
new Promise<string>(resolve => {
|
|
1996
2073
|
notifyStdinClosed = () => {
|
|
@@ -1999,6 +2076,29 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1999
2076
|
};
|
|
2000
2077
|
}),
|
|
2001
2078
|
]);
|
|
2079
|
+
// PASTE-MERGE: if the resolving line came from inside a bracketed paste, the rest of
|
|
2080
|
+
// the paste (buffered in pasteMerge.buf) plus the trailing residual still in the line
|
|
2081
|
+
// buffer belong to the SAME message. Wait for paste-end, then join them with newlines
|
|
2082
|
+
// and return ONE multi-line input instead of running line 1 and queuing the rest.
|
|
2083
|
+
if (pasteLineFired) {
|
|
2084
|
+
if (promptPasteActive) {
|
|
2085
|
+
await new Promise<void>(resolve => {
|
|
2086
|
+
if (!promptPasteActive) { resolve(); return; }
|
|
2087
|
+
pasteMerge.endWaiters.push(resolve);
|
|
2088
|
+
setTimeout(resolve, 250); // safety: never hang if the 201~ marker is dropped
|
|
2089
|
+
});
|
|
2090
|
+
}
|
|
2091
|
+
const residual = (rl as unknown as { line?: string }).line ?? "";
|
|
2092
|
+
// Clear the residual so it does NOT prefill (and re-submit) the next prompt.
|
|
2093
|
+
try { rl.write(null, { ctrl: true, name: "u" }); } catch { /* best-effort */ }
|
|
2094
|
+
try { (rl as unknown as { line: string; cursor: number }).line = ""; (rl as unknown as { cursor: number }).cursor = 0; } catch { /* best-effort */ }
|
|
2095
|
+
const parts = [...pasteMerge.buf];
|
|
2096
|
+
if (residual !== "") parts.push(residual);
|
|
2097
|
+
pasteMerge.buf = [];
|
|
2098
|
+
pasteLineFired = false;
|
|
2099
|
+
return expandSentinel(parts.join("\n"));
|
|
2100
|
+
}
|
|
2101
|
+
return expandSentinel(value);
|
|
2002
2102
|
} finally {
|
|
2003
2103
|
promptActive = false;
|
|
2004
2104
|
notifyStdinClosed = undefined;
|
|
@@ -2148,6 +2248,27 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2148
2248
|
let uiTheme = resolveTheme(process.env);
|
|
2149
2249
|
let uiAccent = accentPaint(uiTheme);
|
|
2150
2250
|
let uiAccentShadow = accentShadowPaint(uiTheme);
|
|
2251
|
+
// Input-box border colors. Each opened session gets a DISTINCT hue (so several jeo
|
|
2252
|
+
// sessions are tellable apart at a glance), and cmd-mode (`!`) overrides it with a
|
|
2253
|
+
// caution amber so entering the shell escape is unmistakable.
|
|
2254
|
+
const SESSION_BOX_ACCENTS = ["#48dbfb", "#39ff14", "#a29bfe", "#1dd1a1", "#ff9ff3", "#54a0ff", "#ff6b81", "#c8d6e5"];
|
|
2255
|
+
const CMD_MODE_BOX_ACCENT = "#ffb300";
|
|
2256
|
+
const hexPaint = (hex: string) => (s: string) => chalk.hex(hex)(s);
|
|
2257
|
+
const hexShadowPaint = (hex: string) => (s: string) => chalk.dim(chalk.hex(hex)(s));
|
|
2258
|
+
// Per-process random start so different jeo processes differ at a glance; advanced on
|
|
2259
|
+
// each newly opened session (advanceSessionBoxColor) so consecutive sessions never match.
|
|
2260
|
+
let sessionBoxColorIdx = Math.floor(Math.random() * SESSION_BOX_ACCENTS.length);
|
|
2261
|
+
const advanceSessionBoxColor = (): void => {
|
|
2262
|
+
sessionBoxColorIdx = (sessionBoxColorIdx + 1) % SESSION_BOX_ACCENTS.length;
|
|
2263
|
+
};
|
|
2264
|
+
// Resolve the box painters for the current draft: cmd-mode amber when it starts with
|
|
2265
|
+
// `!`, else the per-session hue, else the theme accent (colorless theme / no session).
|
|
2266
|
+
const boxAccents = (line: string): { accent: (s: string) => string; shadow: (s: string) => string } => {
|
|
2267
|
+
if (!uiTheme.color) return { accent: uiAccent, shadow: uiAccentShadow };
|
|
2268
|
+
if (line.startsWith("!")) return { accent: hexPaint(CMD_MODE_BOX_ACCENT), shadow: hexShadowPaint(CMD_MODE_BOX_ACCENT) };
|
|
2269
|
+
if (sessionId) { const hex = SESSION_BOX_ACCENTS[sessionBoxColorIdx]!; return { accent: hexPaint(hex), shadow: hexShadowPaint(hex) }; }
|
|
2270
|
+
return { accent: uiAccent, shadow: uiAccentShadow };
|
|
2271
|
+
};
|
|
2151
2272
|
const refreshUiTheme = (): void => {
|
|
2152
2273
|
uiTheme = resolveTheme(process.env);
|
|
2153
2274
|
uiAccent = accentPaint(uiTheme);
|
|
@@ -2182,12 +2303,13 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2182
2303
|
// sits at the end of the text.
|
|
2183
2304
|
const rli = rl as unknown as { line?: string; cursor?: number };
|
|
2184
2305
|
const caret = rli.line === line && typeof rli.cursor === "number" ? rli.cursor : line.length;
|
|
2185
|
-
const
|
|
2306
|
+
const { accent: boxAccent, shadow: boxShadow } = boxAccents(line);
|
|
2307
|
+
const frame = renderInputFrame(expandSentinel(line), {
|
|
2186
2308
|
cols,
|
|
2187
2309
|
color: true,
|
|
2188
2310
|
unicode: true,
|
|
2189
|
-
accent:
|
|
2190
|
-
accentShadow:
|
|
2311
|
+
accent: boxAccent,
|
|
2312
|
+
accentShadow: boxShadow,
|
|
2191
2313
|
cwdLabel: currentAtLabel(line),
|
|
2192
2314
|
attachmentLabel: pendingImages.length
|
|
2193
2315
|
? `⧉ ${pendingImages.length} image${pendingImages.length > 1 ? "s" : ""} attached — sent with the next message`
|
|
@@ -3062,6 +3184,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
3062
3184
|
history.length = 1;
|
|
3063
3185
|
if (!flags.noSession) {
|
|
3064
3186
|
sessionId = (await createSession(cwd)).id;
|
|
3187
|
+
advanceSessionBoxColor(); // distinct input-box hue per newly opened session
|
|
3065
3188
|
console.log(`(${verb} — new session ${sessionId})`);
|
|
3066
3189
|
} else {
|
|
3067
3190
|
sessionId = undefined;
|
|
@@ -4106,6 +4229,25 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
4106
4229
|
}
|
|
4107
4230
|
continue;
|
|
4108
4231
|
}
|
|
4232
|
+
// `$a $b ... [intent]` — run EVERY resolved skill in the leading run, in order, each
|
|
4233
|
+
// with the shared trailing intent. A lone `$skill` is just a chain of length 1.
|
|
4234
|
+
const dollarChain = input.startsWith("$") ? parseSkillChain(input, resolvedSkills) : null;
|
|
4235
|
+
if (dollarChain && dollarChain.invocations.length) {
|
|
4236
|
+
if (dollarChain.unresolved.length) {
|
|
4237
|
+
console.log(`(skipping unknown skill${dollarChain.unresolved.length > 1 ? "s" : ""}: ${dollarChain.unresolved.map(u => `$${u}`).join(", ")})`);
|
|
4238
|
+
}
|
|
4239
|
+
if (dollarChain.invocations.length > 1) {
|
|
4240
|
+
console.log(`▶ Chaining ${dollarChain.invocations.length} skills: ${dollarChain.invocations.map(i => `$${i.skill.name}`).join(" → ")}`);
|
|
4241
|
+
}
|
|
4242
|
+
for (const inv of dollarChain.invocations) {
|
|
4243
|
+
try {
|
|
4244
|
+
await runSkillInvocation(inv.skill, inv.intent, inv.invokedAs);
|
|
4245
|
+
} catch (err) {
|
|
4246
|
+
console.log(`! ${(err as Error).message}`);
|
|
4247
|
+
}
|
|
4248
|
+
}
|
|
4249
|
+
continue;
|
|
4250
|
+
}
|
|
4109
4251
|
const aliasInvocation = parseSkillInvocation(input, resolvedSkills);
|
|
4110
4252
|
if (aliasInvocation?.invokedAs) {
|
|
4111
4253
|
try {
|
|
@@ -4115,6 +4257,33 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
4115
4257
|
}
|
|
4116
4258
|
continue;
|
|
4117
4259
|
}
|
|
4260
|
+
// Unresolved `$skill` → suggest precisely, never silently send the typo to the model.
|
|
4261
|
+
// `$exact`/`$prefix`/chains already ran above; a leftover `$word` is a missed skill
|
|
4262
|
+
// attempt, EXCEPT `$UPPERCASE` env-var-style tokens (e.g. `$HOME`) which pass through.
|
|
4263
|
+
if (input.startsWith("$")) {
|
|
4264
|
+
const unresolved = (dollarChain?.unresolved.length
|
|
4265
|
+
? dollarChain.unresolved
|
|
4266
|
+
: [(input.split(/\s+/, 1)[0] ?? "").slice(1)]
|
|
4267
|
+
).filter(t => t && !/^[A-Z_][A-Z0-9_]*$/.test(t));
|
|
4268
|
+
if (unresolved.length > 1) {
|
|
4269
|
+
console.log(`No skills: ${unresolved.map(u => `$${u}`).join(", ")}. (Type $ to autocomplete.)`);
|
|
4270
|
+
continue;
|
|
4271
|
+
}
|
|
4272
|
+
if (unresolved.length === 1) {
|
|
4273
|
+
const token = unresolved[0]!;
|
|
4274
|
+
const lc = token.toLowerCase();
|
|
4275
|
+
const prefix = resolvedSkills.filter(s => s.name.toLowerCase().startsWith(lc));
|
|
4276
|
+
if (prefix.length) {
|
|
4277
|
+
console.log(`Ambiguous skill '$${token}'. Did you mean: ${prefix.slice(0, 6).map(s => `$${s.name}`).join(", ")}?`);
|
|
4278
|
+
} else {
|
|
4279
|
+
const names = resolvedSkills.map(s => `$${s.name}`);
|
|
4280
|
+
const shown = names.slice(0, 12).join(", ");
|
|
4281
|
+
const more = names.length > 12 ? ` … +${names.length - 12} more` : "";
|
|
4282
|
+
console.log(`No skill '$${token}'. ${names.length ? `Available: ${shown}${more}` : "No skills are loaded."} (Type $ to autocomplete.)`);
|
|
4283
|
+
}
|
|
4284
|
+
continue;
|
|
4285
|
+
}
|
|
4286
|
+
}
|
|
4118
4287
|
// Unhandled slash attempt → suggest, don't send the typo to the model.
|
|
4119
4288
|
if (isSlashAttempt(input)) {
|
|
4120
4289
|
const m = matchSlash(input, [...completionContext().slashCommands]);
|
package/src/skills/catalog.ts
CHANGED
|
@@ -475,6 +475,32 @@ export function getSkillFrom(skills: SkillDoc[], name: string): SkillDoc | undef
|
|
|
475
475
|
return skills.find(s => s.name.toLowerCase() === name.toLowerCase());
|
|
476
476
|
}
|
|
477
477
|
|
|
478
|
+
/** The single skill whose name PREFIX-matches `query` (case-insensitive), or undefined
|
|
479
|
+
* when zero or many match. Lets `$te` precisely resolve to `$team` without full spelling. */
|
|
480
|
+
export function uniquePrefixSkill(skills: SkillDoc[], query: string): SkillDoc | undefined {
|
|
481
|
+
const q = query.toLowerCase();
|
|
482
|
+
if (!q) return undefined;
|
|
483
|
+
const hits = skills.filter(s => s.name.toLowerCase().startsWith(q));
|
|
484
|
+
return hits.length === 1 ? hits[0] : undefined;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Prefix-first, then fuzzy-subsequence skill suggestions for a `$query` that did NOT
|
|
488
|
+
* resolve — drives the REPL's clear "did you mean / available" feedback. */
|
|
489
|
+
export function suggestSkills(skills: SkillDoc[], query: string): SkillDoc[] {
|
|
490
|
+
const q = query.toLowerCase();
|
|
491
|
+
const prefix = skills.filter(s => s.name.toLowerCase().startsWith(q));
|
|
492
|
+
const seen = new Set(prefix.map(s => s.name));
|
|
493
|
+
const fuzzy = skills.filter(s => !seen.has(s.name) && skillNameSubsequence(q, s.name.toLowerCase()));
|
|
494
|
+
return [...prefix, ...fuzzy];
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Order-preserving subsequence test (every char of `needle` appears in `hay` L→R). */
|
|
498
|
+
function skillNameSubsequence(needle: string, hay: string): boolean {
|
|
499
|
+
let i = 0;
|
|
500
|
+
for (let j = 0; j < hay.length && i < needle.length; j++) if (hay[j] === needle[i]) i++;
|
|
501
|
+
return i === needle.length;
|
|
502
|
+
}
|
|
503
|
+
|
|
478
504
|
/** Case-insensitive lookup by direct slash alias, e.g. `/speckit.plan`. */
|
|
479
505
|
export function getSkillBySlash(skills: SkillDoc[], command: string): SkillDoc | undefined {
|
|
480
506
|
const q = command.toLowerCase();
|
|
@@ -514,7 +540,7 @@ export function parseSkillInvocation(input: string, skills: SkillDoc[]): SkillIn
|
|
|
514
540
|
// only when a skill with that exact name is loaded — `$HOME is what?` or any
|
|
515
541
|
// unknown `$word` falls through to the model as an ordinary prompt.
|
|
516
542
|
if (command.length > 1 && command.startsWith("$")) {
|
|
517
|
-
const dollarSkill = getSkillFrom(skills, command.slice(1));
|
|
543
|
+
const dollarSkill = getSkillFrom(skills, command.slice(1)) ?? uniquePrefixSkill(skills, command.slice(1));
|
|
518
544
|
if (dollarSkill) {
|
|
519
545
|
return { skill: dollarSkill, intent: trimmed.slice(command.length).trim(), invokedAs: command };
|
|
520
546
|
}
|
|
@@ -530,6 +556,38 @@ export function parseSkillInvocation(input: string, skills: SkillDoc[]): SkillIn
|
|
|
530
556
|
}
|
|
531
557
|
return skill ? { skill, intent: trimmed.slice(command.length).trim(), invokedAs: command } : null;
|
|
532
558
|
}
|
|
559
|
+
|
|
560
|
+
/** Parse a LEADING run of `$skill` tokens into an ordered chain that shares the trailing
|
|
561
|
+
* text as one intent: `$ralplan $team build auth` → [ralplan, team] each with intent
|
|
562
|
+
* "build auth". This is what lets `$` invoke several skills in one line — they all run,
|
|
563
|
+
* in order. Scanning stops at the first non-`$` token, OR a `$UPPERCASE` env-var-style
|
|
564
|
+
* token (e.g. `$HOME`), which is left in the intent so shell-style references pass through.
|
|
565
|
+
* Each `$name` resolves by exact name then unique prefix; names that resolve to nothing go
|
|
566
|
+
* into `unresolved` (so the REPL can report every typo, not just the first). Returns null
|
|
567
|
+
* only when the input opens with no parseable `$skill` token at all. */
|
|
568
|
+
export function parseSkillChain(
|
|
569
|
+
input: string,
|
|
570
|
+
skills: SkillDoc[],
|
|
571
|
+
): { invocations: SkillInvocation[]; unresolved: string[] } | null {
|
|
572
|
+
const trimmed = input.trim();
|
|
573
|
+
if (!trimmed.startsWith("$")) return null;
|
|
574
|
+
const tokens = trimmed.split(/\s+/);
|
|
575
|
+
const invocations: SkillInvocation[] = [];
|
|
576
|
+
const unresolved: string[] = [];
|
|
577
|
+
let i = 0;
|
|
578
|
+
for (; i < tokens.length; i++) {
|
|
579
|
+
const tok = tokens[i] ?? "";
|
|
580
|
+
if (!tok.startsWith("$") || tok.length < 2) break;
|
|
581
|
+
const name = tok.slice(1);
|
|
582
|
+
if (/^[A-Z_][A-Z0-9_]*$/.test(name)) break; // env-var-style → boundary; keep in intent
|
|
583
|
+
const skill = getSkillFrom(skills, name) ?? uniquePrefixSkill(skills, name);
|
|
584
|
+
if (skill) invocations.push({ skill, intent: "", invokedAs: tok });
|
|
585
|
+
else unresolved.push(name);
|
|
586
|
+
}
|
|
587
|
+
if (invocations.length === 0 && unresolved.length === 0) return null;
|
|
588
|
+
const intent = tokens.slice(i).join(" ").trim();
|
|
589
|
+
return { invocations: invocations.map(inv => ({ ...inv, intent })), unresolved };
|
|
590
|
+
}
|
|
533
591
|
export function looksLikeSkillEcho(reply: string, skills: SkillDoc[]): boolean {
|
|
534
592
|
if (reply.length < 80) {
|
|
535
593
|
return false;
|