jeo-code 0.6.4 → 0.6.6
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 +25 -0
- 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/flags.ts +242 -0
- package/src/commands/launch/input.ts +330 -0
- package/src/commands/launch/stream.ts +102 -0
- package/src/commands/launch/tmux.ts +227 -0
- package/src/commands/launch/workflow.ts +26 -0
- package/src/commands/launch.ts +176 -967
- package/src/tui/components/input-box.ts +56 -0
- package/src/tui/components/welcome.ts +28 -6
- package/src/tui/renderer.ts +6 -2
- package/src/tui/terminal.ts +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
6
6
|
|
|
7
7
|
The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
|
|
8
8
|
|
|
9
|
+
## [0.6.6] - 2026-06-16
|
|
10
|
+
_Vertical caret movement between input-box rows, a centered welcome banner, and a leaner `parseFlags`._
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Vertical caret movement in the boxed prompt.** ↑/↓ inside a multi-line or wrapped draft now move the caret between the input box's visual rows (textarea feel) via `verticalCursorOffset`; an ↑/↓ at the top/bottom edge still falls through to readline history recall.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- **Welcome banner is centered.**
|
|
17
|
+
- **`parseFlags` simplified** — duplicate `--flag` / `--flag=` branches collapsed into one (`takeValue()` already resolves both spellings), −40 lines with zero behavior change.
|
|
18
|
+
|
|
19
|
+
## [0.6.5] - 2026-06-16
|
|
20
|
+
_macOS combo-key editing in the boxed prompt, a fresh-start screen clear at launch, a proportional welcome banner, height-aware relayout — and `launch.ts` split into focused submodules._
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- **macOS / fixterms combo keys in the boxed prompt.** Option+Left/Right (word jump) and Cmd+Left/Right (line start/end), plus Option/Cmd+Backspace, are normalized to the canonical control bytes Bun's readline already acts on — so the keys macOS users actually reach for now move the caret instead of doing nothing. Readline stays the single owner of the cursor; the box just repaints.
|
|
24
|
+
- **Fresh-start screen clear at launch.** The banner opens atop a cleared screen (erase screen + scrollback) on a TTY — never mid-turn, so tmux scrollback is never flooded.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
- **Welcome banner uses a natural, proportional hero width** instead of stretching into a mostly-empty rectangle on wide terminals; it shrinks gracefully (grand→compact claw) on narrow ones so the art keeps its shape and never clips.
|
|
28
|
+
- **`launch.ts` split into focused submodules** under `src/commands/launch/` (`flags`, `input`, `tmux`, `stream`, `workflow`) — a ~1000-line maintainability refactor with no behavior change; the public surface is re-exported unchanged.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
- **Renderer relayouts on height change**, not just width — a terminal that grows/shrinks vertically now repaints correctly.
|
|
32
|
+
- **Pickers no longer leave typed filter text behind.** A `/model` · `/agents` picker that read keystrokes directly no longer queues its leftover filter text as the next prompt.
|
|
33
|
+
|
|
9
34
|
## [0.6.4] - 2026-06-16
|
|
10
35
|
_Branding, a responsive-resize fix, `/provider` realignment, and engine repeat-spin recovery._
|
|
11
36
|
|
package/README.ja.md
CHANGED
|
@@ -162,11 +162,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
162
162
|
## 変更履歴 (Changelog)
|
|
163
163
|
|
|
164
164
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
165
|
+
- **[0.6.6]** (2026-06-16) — Vertical caret movement between input-box rows, a centered welcome banner, and a leaner `parseFlags`.
|
|
166
|
+
- **[0.6.5]** (2026-06-16) — macOS combo-key editing in the boxed prompt, a fresh-start screen clear at launch, a proportional welcome banner, height-aware relayout — and `launch.ts` split into focused submodules.
|
|
165
167
|
- **[0.6.4]** (2026-06-16) — Branding, a responsive-resize fix, `/provider` realignment, and engine repeat-spin recovery.
|
|
166
168
|
- **[0.6.3]** (2026-06-16) — OAuth loopback reliability fix.
|
|
167
169
|
- **[0.6.2]** (2026-06-16) — Interactive `/provider` picker, clearer animated status + labeled block/prose boundaries, and a transient empty-response retry.
|
|
168
|
-
- **[0.6.1]** (2026-06-16) — Live reasoning progress (no more frozen "calling model"), thinking-level fixes for Anthropic/Antigravity, and input-box/Ctrl+O TUI fixes.
|
|
169
|
-
- **[0.6.0]** (2026-06-16) — TUI quality of life: durable input history (↑ recalls past queries across launches), clean `/resume` rendering, and a scrollable mid-turn Ctrl+O panel.
|
|
170
170
|
|
|
171
171
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
172
172
|
<!-- CHANGELOG:END -->
|
package/README.ko.md
CHANGED
|
@@ -162,11 +162,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
162
162
|
## 변경 이력 (Changelog)
|
|
163
163
|
|
|
164
164
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
165
|
+
- **[0.6.6]** (2026-06-16) — Vertical caret movement between input-box rows, a centered welcome banner, and a leaner `parseFlags`.
|
|
166
|
+
- **[0.6.5]** (2026-06-16) — macOS combo-key editing in the boxed prompt, a fresh-start screen clear at launch, a proportional welcome banner, height-aware relayout — and `launch.ts` split into focused submodules.
|
|
165
167
|
- **[0.6.4]** (2026-06-16) — Branding, a responsive-resize fix, `/provider` realignment, and engine repeat-spin recovery.
|
|
166
168
|
- **[0.6.3]** (2026-06-16) — OAuth loopback reliability fix.
|
|
167
169
|
- **[0.6.2]** (2026-06-16) — Interactive `/provider` picker, clearer animated status + labeled block/prose boundaries, and a transient empty-response retry.
|
|
168
|
-
- **[0.6.1]** (2026-06-16) — Live reasoning progress (no more frozen "calling model"), thinking-level fixes for Anthropic/Antigravity, and input-box/Ctrl+O TUI fixes.
|
|
169
|
-
- **[0.6.0]** (2026-06-16) — TUI quality of life: durable input history (↑ recalls past queries across launches), clean `/resume` rendering, and a scrollable mid-turn Ctrl+O panel.
|
|
170
170
|
|
|
171
171
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
172
172
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -162,11 +162,11 @@ Required npm token permissions (repository secret `NPM_TOKEN`):
|
|
|
162
162
|
## Changelog
|
|
163
163
|
|
|
164
164
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
165
|
+
- **[0.6.6]** (2026-06-16) — Vertical caret movement between input-box rows, a centered welcome banner, and a leaner `parseFlags`.
|
|
166
|
+
- **[0.6.5]** (2026-06-16) — macOS combo-key editing in the boxed prompt, a fresh-start screen clear at launch, a proportional welcome banner, height-aware relayout — and `launch.ts` split into focused submodules.
|
|
165
167
|
- **[0.6.4]** (2026-06-16) — Branding, a responsive-resize fix, `/provider` realignment, and engine repeat-spin recovery.
|
|
166
168
|
- **[0.6.3]** (2026-06-16) — OAuth loopback reliability fix.
|
|
167
169
|
- **[0.6.2]** (2026-06-16) — Interactive `/provider` picker, clearer animated status + labeled block/prose boundaries, and a transient empty-response retry.
|
|
168
|
-
- **[0.6.1]** (2026-06-16) — Live reasoning progress (no more frozen "calling model"), thinking-level fixes for Anthropic/Antigravity, and input-box/Ctrl+O TUI fixes.
|
|
169
|
-
- **[0.6.0]** (2026-06-16) — TUI quality of life: durable input history (↑ recalls past queries across launches), clean `/resume` rendering, and a scrollable mid-turn Ctrl+O panel.
|
|
170
170
|
|
|
171
171
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
172
172
|
<!-- CHANGELOG:END -->
|
package/README.zh.md
CHANGED
|
@@ -162,11 +162,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
162
162
|
## 更新日志 (Changelog)
|
|
163
163
|
|
|
164
164
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
165
|
+
- **[0.6.6]** (2026-06-16) — Vertical caret movement between input-box rows, a centered welcome banner, and a leaner `parseFlags`.
|
|
166
|
+
- **[0.6.5]** (2026-06-16) — macOS combo-key editing in the boxed prompt, a fresh-start screen clear at launch, a proportional welcome banner, height-aware relayout — and `launch.ts` split into focused submodules.
|
|
165
167
|
- **[0.6.4]** (2026-06-16) — Branding, a responsive-resize fix, `/provider` realignment, and engine repeat-spin recovery.
|
|
166
168
|
- **[0.6.3]** (2026-06-16) — OAuth loopback reliability fix.
|
|
167
169
|
- **[0.6.2]** (2026-06-16) — Interactive `/provider` picker, clearer animated status + labeled block/prose boundaries, and a transient empty-response retry.
|
|
168
|
-
- **[0.6.1]** (2026-06-16) — Live reasoning progress (no more frozen "calling model"), thinking-level fixes for Anthropic/Antigravity, and input-box/Ctrl+O TUI fixes.
|
|
169
|
-
- **[0.6.0]** (2026-06-16) — TUI quality of life: durable input history (↑ recalls past queries across launches), clean `/resume` rendering, and a scrollable mid-turn Ctrl+O panel.
|
|
170
170
|
|
|
171
171
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
172
172
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { type ProviderName, type ModelRole, type ThinkLevel, catalogMetadata } from "../../ai";
|
|
4
|
+
|
|
5
|
+
export interface LaunchFlags {
|
|
6
|
+
list: boolean;
|
|
7
|
+
resume: boolean;
|
|
8
|
+
resumeId?: string;
|
|
9
|
+
noSession: boolean;
|
|
10
|
+
noTui: boolean;
|
|
11
|
+
/** Explicit step cap from --max-steps; 0 = dynamic (process-driven budget that
|
|
12
|
+
* keeps extending while the turn shows progress — no hardcoded step ceiling). */
|
|
13
|
+
maxSteps: number;
|
|
14
|
+
message: string;
|
|
15
|
+
tmux: boolean;
|
|
16
|
+
worktree?: string;
|
|
17
|
+
model?: string;
|
|
18
|
+
provider?: ProviderName;
|
|
19
|
+
modelRole?: ModelRole;
|
|
20
|
+
thinking?: ThinkLevel;
|
|
21
|
+
errors: string[];
|
|
22
|
+
print?: boolean;
|
|
23
|
+
appendSystemPromptRaw?: string;
|
|
24
|
+
appendSystemPrompt?: string;
|
|
25
|
+
noSkills: boolean;
|
|
26
|
+
skills?: string;
|
|
27
|
+
noTools: boolean;
|
|
28
|
+
tools?: string;
|
|
29
|
+
systemPromptRaw?: string;
|
|
30
|
+
systemPrompt?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function takeValue(args: string[], index: number, inlinePrefix: string): { value?: string; nextIndex: number } {
|
|
34
|
+
const current = args[index]!;
|
|
35
|
+
if (current.startsWith(inlinePrefix)) return { value: current.slice(inlinePrefix.length), nextIndex: index };
|
|
36
|
+
const next = args[index + 1];
|
|
37
|
+
if (next && !next.startsWith("-")) return { value: next, nextIndex: index + 1 };
|
|
38
|
+
return { nextIndex: index };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isProviderName(input: string | undefined): input is ProviderName {
|
|
42
|
+
return input === "anthropic" || input === "openai" || input === "gemini" || input === "antigravity" || input === "ollama";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isThinkingLevel(input: string | undefined): input is ThinkLevel {
|
|
46
|
+
return input === "minimal" || input === "low" || input === "medium" || input === "high" || input === "xhigh";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
export function fastThinkingLevelForModel(modelId: string): ThinkLevel | undefined {
|
|
51
|
+
const supported = catalogMetadata(modelId)?.thinking ?? [];
|
|
52
|
+
if (supported.includes("minimal")) return "minimal";
|
|
53
|
+
if (supported.includes("low")) return "low";
|
|
54
|
+
if (/gemini-(2\.5|[3-9])/.test(modelId.toLowerCase())) return "minimal";
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function parseFlags(args: string[], cwd: string = process.cwd()): LaunchFlags {
|
|
59
|
+
const flags: LaunchFlags = { list: false, resume: false, noSession: false, noTui: false, maxSteps: 0, message: "", tmux: false, errors: [], print: false, noSkills: false, noTools: false };
|
|
60
|
+
const rest: string[] = [];
|
|
61
|
+
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
62
|
+
for (let i = 0; i < args.length; i++) {
|
|
63
|
+
const a = args[i];
|
|
64
|
+
if (a === "--") {
|
|
65
|
+
rest.push(...args.slice(i + 1));
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
if (a === "--list") {
|
|
69
|
+
flags.list = true;
|
|
70
|
+
} else if (a === "-p" || a === "--print") {
|
|
71
|
+
flags.print = true;
|
|
72
|
+
flags.noTui = true;
|
|
73
|
+
} else if (a === "--tmux") {
|
|
74
|
+
flags.tmux = true;
|
|
75
|
+
} else if (a === "--worktree") {
|
|
76
|
+
const next = args[i + 1];
|
|
77
|
+
if (next && !next.startsWith("-")) {
|
|
78
|
+
flags.worktree = next;
|
|
79
|
+
i++;
|
|
80
|
+
}
|
|
81
|
+
} else if (a.startsWith("--worktree=")) {
|
|
82
|
+
flags.worktree = a.slice("--worktree=".length);
|
|
83
|
+
} else if (a === "--no-session") {
|
|
84
|
+
flags.noSession = true;
|
|
85
|
+
} else if (a === "--no-tui") {
|
|
86
|
+
flags.noTui = true;
|
|
87
|
+
} else if (a === "--max-steps" || a.startsWith("--max-steps=")) {
|
|
88
|
+
const { value, nextIndex } = takeValue(args, i, "--max-steps=");
|
|
89
|
+
const n = parseInt(value ?? "", 10);
|
|
90
|
+
if (Number.isFinite(n) && n > 0) flags.maxSteps = n;
|
|
91
|
+
i = nextIndex;
|
|
92
|
+
} else if (a === "--model" || a.startsWith("--model=")) {
|
|
93
|
+
const { value, nextIndex } = takeValue(args, i, "--model=");
|
|
94
|
+
if (value) flags.model = value;
|
|
95
|
+
else flags.errors.push("--model requires a value");
|
|
96
|
+
i = nextIndex;
|
|
97
|
+
} else if (a === "--provider" || a.startsWith("--provider=")) {
|
|
98
|
+
const { value, nextIndex } = takeValue(args, i, "--provider=");
|
|
99
|
+
const normalized = value?.toLowerCase();
|
|
100
|
+
if (isProviderName(normalized)) flags.provider = normalized;
|
|
101
|
+
else flags.errors.push("--provider must be one of: anthropic, openai, gemini, ollama");
|
|
102
|
+
i = nextIndex;
|
|
103
|
+
} else if (a === "--thinking" || a.startsWith("--thinking=")) {
|
|
104
|
+
const { value, nextIndex } = takeValue(args, i, "--thinking=");
|
|
105
|
+
const normalized = value?.toLowerCase();
|
|
106
|
+
if (isThinkingLevel(normalized)) flags.thinking = normalized;
|
|
107
|
+
else flags.errors.push("--thinking must be one of: minimal, low, medium, high, xhigh");
|
|
108
|
+
i = nextIndex;
|
|
109
|
+
} else if (a === "--smol" || a === "--slow" || a === "--plan") {
|
|
110
|
+
flags.modelRole = a.slice(2) as ModelRole;
|
|
111
|
+
} else if (a === "--resume" || a === "--continue" || a === "-c") {
|
|
112
|
+
flags.resume = true;
|
|
113
|
+
const next = args[i + 1];
|
|
114
|
+
if (next && UUID_REGEX.test(next)) {
|
|
115
|
+
flags.resumeId = next;
|
|
116
|
+
i++;
|
|
117
|
+
}
|
|
118
|
+
} else if (a.startsWith("--resume=") || a.startsWith("--continue=") || a.startsWith("-c=")) {
|
|
119
|
+
flags.resume = true;
|
|
120
|
+
const eqIdx = a.indexOf("=");
|
|
121
|
+
const val = a.slice(eqIdx + 1);
|
|
122
|
+
if (UUID_REGEX.test(val)) {
|
|
123
|
+
flags.resumeId = val;
|
|
124
|
+
} else {
|
|
125
|
+
rest.push(val);
|
|
126
|
+
}
|
|
127
|
+
} else if (a === "--append-system-prompt" || a.startsWith("--append-system-prompt=")) {
|
|
128
|
+
const { value, nextIndex } = takeValue(args, i, "--append-system-prompt=");
|
|
129
|
+
if (value) flags.appendSystemPromptRaw = value;
|
|
130
|
+
else flags.errors.push("--append-system-prompt requires a value");
|
|
131
|
+
i = nextIndex;
|
|
132
|
+
} else if (a === "--no-skills") {
|
|
133
|
+
flags.noSkills = true;
|
|
134
|
+
} else if (a === "--skills" || a.startsWith("--skills=")) {
|
|
135
|
+
const { value, nextIndex } = takeValue(args, i, "--skills=");
|
|
136
|
+
if (value) flags.skills = value;
|
|
137
|
+
else flags.errors.push("--skills requires a value");
|
|
138
|
+
i = nextIndex;
|
|
139
|
+
} else if (a === "--no-tools") {
|
|
140
|
+
flags.noTools = true;
|
|
141
|
+
} else if (a === "--tools" || a.startsWith("--tools=")) {
|
|
142
|
+
const { value, nextIndex } = takeValue(args, i, "--tools=");
|
|
143
|
+
if (value) flags.tools = value;
|
|
144
|
+
else flags.errors.push("--tools requires a value");
|
|
145
|
+
i = nextIndex;
|
|
146
|
+
} else if (a === "--system-prompt" || a.startsWith("--system-prompt=")) {
|
|
147
|
+
const { value, nextIndex } = takeValue(args, i, "--system-prompt=");
|
|
148
|
+
if (value) flags.systemPromptRaw = value;
|
|
149
|
+
else flags.errors.push("--system-prompt requires a value");
|
|
150
|
+
i = nextIndex;
|
|
151
|
+
} else {
|
|
152
|
+
rest.push(a);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
flags.message = rest.join(" ").trim();
|
|
156
|
+
|
|
157
|
+
if (flags.print && !flags.message) {
|
|
158
|
+
flags.errors.push("-p/--print requires a message argument");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (flags.appendSystemPromptRaw) {
|
|
162
|
+
if (flags.appendSystemPromptRaw.startsWith("@")) {
|
|
163
|
+
const filePath = flags.appendSystemPromptRaw.slice(1);
|
|
164
|
+
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
|
165
|
+
try {
|
|
166
|
+
flags.appendSystemPrompt = fs.readFileSync(absPath, "utf8");
|
|
167
|
+
} catch (err) {
|
|
168
|
+
flags.errors.push(`failed to read system prompt file: ${(err as Error).message}`);
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
flags.appendSystemPrompt = flags.appendSystemPromptRaw;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (flags.systemPromptRaw) {
|
|
175
|
+
if (flags.systemPromptRaw.startsWith("@")) {
|
|
176
|
+
const filePath = flags.systemPromptRaw.slice(1);
|
|
177
|
+
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
|
178
|
+
try {
|
|
179
|
+
flags.systemPrompt = fs.readFileSync(absPath, "utf8");
|
|
180
|
+
} catch (err) {
|
|
181
|
+
flags.errors.push(`failed to read system prompt file: ${(err as Error).message}`);
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
flags.systemPrompt = flags.systemPromptRaw;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return flags;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function matchSkillGlob(pattern: string, name: string): boolean {
|
|
192
|
+
const p = pattern.toLowerCase();
|
|
193
|
+
const n = name.toLowerCase();
|
|
194
|
+
if (!p.includes("*")) {
|
|
195
|
+
return p === n;
|
|
196
|
+
}
|
|
197
|
+
const escaped = p.replace(/[.+^${}()|[\\\]]/g, "\\$&");
|
|
198
|
+
const regexStr = "^" + escaped.replace(/\*/g, ".*") + "$";
|
|
199
|
+
const regex = new RegExp(regexStr);
|
|
200
|
+
return regex.test(n);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function filterToolMap(
|
|
204
|
+
tools: Record<string, any>,
|
|
205
|
+
allowlist: string[]
|
|
206
|
+
): Record<string, any> {
|
|
207
|
+
const result: Record<string, any> = {};
|
|
208
|
+
for (const name of allowlist) {
|
|
209
|
+
if (name in tools) {
|
|
210
|
+
result[name] = tools[name];
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export const TOOL_DESCRIPTIONS: Record<string, string> = {
|
|
217
|
+
read: "read {filePath, lineRange?, raw?} — read a file; lines are prefixed `LINEhh|` (hh = 2-char content anchor; the | is a separator, not file bytes)",
|
|
218
|
+
write: "write {filePath, content} — create/overwrite a file",
|
|
219
|
+
edit: "edit {filePath, editBlock} — ≔A..B replace lines (append read anchors for safety: ≔12ab..15cd — rejected with fresh content if the lines changed); ≔A+ insert after line A; ≔$ append EOF (payload on next line). NEVER copy the `LINEhh|` prefixes into SEARCH blocks or payloads",
|
|
220
|
+
bash: "bash {command, timeoutMs?, cwd?, env?} — run a shell command (cwd: subdir; env: extra vars)",
|
|
221
|
+
find: "find {globPattern} — find files by name",
|
|
222
|
+
search: "search {pattern, globPattern?, ignoreCase?, context?, maxMatches?} — grep (context: N lines around each match)",
|
|
223
|
+
ls: "ls {dirPath} — list a directory's entries (dirs first)",
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
export function buildToolProtocol(allowedTools: Set<string>): string {
|
|
227
|
+
const lines: string[] = ["You have these tools (call exactly ONE per step):"];
|
|
228
|
+
let num = 1;
|
|
229
|
+
for (const name of ["read", "write", "edit", "bash", "find", "search", "ls"]) {
|
|
230
|
+
if (allowedTools.has(name)) {
|
|
231
|
+
lines.push(`${num}. ${TOOL_DESCRIPTIONS[name]}`);
|
|
232
|
+
num++;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
lines.push(`${num}. done {reason?} — call when the task is fully implemented AND verified`);
|
|
236
|
+
lines.push("");
|
|
237
|
+
lines.push("Reply with STRICT JSON only — no code fences. You MAY include an optional leading");
|
|
238
|
+
lines.push('"reasoning" string (one short sentence on your plan, shown live to the user) before "tool":');
|
|
239
|
+
lines.push('{ "reasoning": "<one short sentence>", "tool": "<name>", "arguments": { ... } }');
|
|
240
|
+
lines.push("Tool calibration: scale calls to difficulty — one for a known fact, a few for a normal task, more only when evidence is genuinely missing. Locate before you open: search/find first, then read the hit, instead of guessing paths.");
|
|
241
|
+
return lines.join("\n");
|
|
242
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
export interface InFlightAbortHarness {
|
|
2
|
+
controller: AbortController;
|
|
3
|
+
handleSigint(): void;
|
|
4
|
+
handleData(chunk: string | Uint8Array): void;
|
|
5
|
+
dispose(): void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface AbortHarnessOptions {
|
|
9
|
+
controller?: AbortController;
|
|
10
|
+
captureEsc?: boolean;
|
|
11
|
+
stdin?: {
|
|
12
|
+
isTTY?: boolean;
|
|
13
|
+
isRaw?: boolean;
|
|
14
|
+
setRawMode?(raw: boolean): void;
|
|
15
|
+
resume?(): void;
|
|
16
|
+
on(event: "data", listener: (chunk: string | Uint8Array) => void): unknown;
|
|
17
|
+
off(event: "data", listener: (chunk: string | Uint8Array) => void): unknown;
|
|
18
|
+
};
|
|
19
|
+
onAbortNotice?: (message: string) => void;
|
|
20
|
+
onHardExit?: () => void;
|
|
21
|
+
/** Invoked when stray escape-sequence noise (wheel scroll etc.) arrives mid-turn. */
|
|
22
|
+
onNoise?: () => void;
|
|
23
|
+
/** Invoked when Ctrl+O (\u000f) is pressed mid-turn — the detail-view binding.
|
|
24
|
+
* Without this hook the byte would be swallowed into the buffered input queue,
|
|
25
|
+
* which is why Ctrl+O historically "did nothing" while the TUI owned stdin. */
|
|
26
|
+
onDetailKey?: () => void;
|
|
27
|
+
/** Invoked when an arrow / PageUp / PageDown key arrives mid-turn — scrolls the
|
|
28
|
+
* open Ctrl+O detail panel. dir -1 = up/back, +1 = down/forward; page = full jump. */
|
|
29
|
+
onScrollKey?: (dir: -1 | 1, page: boolean) => void;
|
|
30
|
+
/** Invoked with printable keyboard input received while the live turn owns stdin. */
|
|
31
|
+
onBufferedInput?: (chunk: string) => void;
|
|
32
|
+
/** True while the input queue is inside a bracketed paste (mid-paste chunks
|
|
33
|
+
* carry no marker and must keep routing to the queue, not the noise path). */
|
|
34
|
+
pasteActive?: () => boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Bracketed-paste markers (DECSET 2004): terminals wrap pasted text in these so
|
|
38
|
+
* an app can treat the paste as DATA instead of keystrokes — the prompt_toolkit
|
|
39
|
+
* paste contract. jeo enables the mode for the REPL TTY so a multi-line paste
|
|
40
|
+
* arrives atomically and executes one command per line, in order. */
|
|
41
|
+
export const PASTE_START = "\u001b[200~";
|
|
42
|
+
export const PASTE_END = "\u001b[201~";
|
|
43
|
+
|
|
44
|
+
/** True when a stdin chunk is ONLY backspace bytes (DEL 0x7f or BS 0x08) — i.e. a
|
|
45
|
+
* standalone Backspace keystroke with nothing else. A backspace on an EMPTY input
|
|
46
|
+
* line is a no-op edit, but some Bun readline builds turn it into a spurious `close`
|
|
47
|
+
* event, which the REPL would treat as a hard exit ("Backspace quits jeo"). The
|
|
48
|
+
* input filter swallows these when the line buffer is already empty so the byte never
|
|
49
|
+
* reaches readline and the close can't fire. */
|
|
50
|
+
export function isStandaloneBackspace(chunk: string): boolean {
|
|
51
|
+
return chunk.length > 0 && /^[\x7f\b]+$/.test(chunk);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* macOS / fixterms combo-key normalization for the boxed prompt's line editor.
|
|
56
|
+
*
|
|
57
|
+
* Bun's readline acts on Ctrl+arrow (CSI `1;5D`/`1;5C` → word jump), Home/End, and the
|
|
58
|
+
* Emacs control bytes (Ctrl+A/E/W/U/K, Meta+b/f/d, Meta+DEL) — but it does NOT act on
|
|
59
|
+
* the modifier-flagged cursor keys macOS users reach for most: Option+Left/Right (word
|
|
60
|
+
* jump, CSI `1;3D`/`1;3C`) and Cmd+Left/Right (line start/end, CSI `1;9D`/`1;9C`) are
|
|
61
|
+
* inert. Rather than racing readline for cursor state in a keypress handler, we rewrite
|
|
62
|
+
* each inert combo to the canonical control byte readline DOES act on, BEFORE it reaches
|
|
63
|
+
* readline. readline stays the single owner of `rl.line`/`rl.cursor`; the box just reads
|
|
64
|
+
* and repaints. Replacement targets are empirically verified against Bun's readline.
|
|
65
|
+
*
|
|
66
|
+
* Modifier digit in `CSI 1;<m><dir>`: 3=Alt/Option, 5=Ctrl (already handled), 9=Cmd/Super.
|
|
67
|
+
* Both the CSI form and the ESC-prefixed alt-as-meta form (`ESC ESC [ D`) are covered. */
|
|
68
|
+
export const CURSOR_COMBO_REWRITES: ReadonlyArray<readonly [string, string]> = [
|
|
69
|
+
["\u001b[1;3D", "\u001bb"], // Option+Left → word left
|
|
70
|
+
["\u001b[1;3C", "\u001bf"], // Option+Right → word right
|
|
71
|
+
["\u001b\u001b[D", "\u001bb"], // Option+Left (ESC-prefixed alt-as-meta)
|
|
72
|
+
["\u001b\u001b[C", "\u001bf"], // Option+Right (ESC-prefixed alt-as-meta)
|
|
73
|
+
["\u001b[1;9D", "\u0001"], // Cmd+Left → line start (Ctrl+A)
|
|
74
|
+
["\u001b[1;9C", "\u0005"], // Cmd+Right → line end (Ctrl+E)
|
|
75
|
+
["\u001b[127;3u", "\u0017"], // Option+Backspace (kitty CSI-u) → delete word left (Ctrl+W)
|
|
76
|
+
["\u001b[127;9u", "\u0015"], // Cmd+Backspace (kitty CSI-u) → delete to line start (Ctrl+U)
|
|
77
|
+
["\u001b[3;3~", "\u001bd"], // Option+Delete (forward) → delete word right (Meta+d)
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/** First combo-key rewrite whose source sequence begins at `data[i]`, else undefined. */
|
|
81
|
+
export function matchCursorCombo(data: string, i: number): readonly [string, string] | undefined {
|
|
82
|
+
for (const pair of CURSOR_COMBO_REWRITES) if (data.startsWith(pair[0], i)) return pair;
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Apply combo-key rewrites across a plain (non-paste) input segment. Shares
|
|
87
|
+
* `matchCursorCombo` with the live input filter, so the filter and this exported
|
|
88
|
+
* helper can never diverge. */
|
|
89
|
+
export function rewriteCursorCombos(plain: string): string {
|
|
90
|
+
let out = "";
|
|
91
|
+
let i = 0;
|
|
92
|
+
while (i < plain.length) {
|
|
93
|
+
const combo = matchCursorCombo(plain, i);
|
|
94
|
+
if (combo) { out += combo[1]; i += combo[0].length; continue; }
|
|
95
|
+
out += plain[i];
|
|
96
|
+
i += 1;
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface PromptInputQueue {
|
|
102
|
+
pendingLines: string[];
|
|
103
|
+
partial: string;
|
|
104
|
+
/** Complete lines that arrived inside a bracketed PASTE: intentional batch
|
|
105
|
+
* commands, served one per prompt in order. Never folded into the typed-line
|
|
106
|
+
* prefill (that contract is for keystrokes typed during a live turn). */
|
|
107
|
+
pastedLines: string[];
|
|
108
|
+
/** True while a bracketed paste spans chunks (between \x1b[200~ and \x1b[201~). */
|
|
109
|
+
inPaste: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Typed (non-paste) keystrokes: printable chars build the partial, Enter promotes
|
|
113
|
+
* it to pendingLines, backspace edits — ESC/ctrl noise segments are rejected. */
|
|
114
|
+
function feedTypedSegment(state: PromptInputQueue, segment: string): boolean {
|
|
115
|
+
if (!segment || segment.includes("\u001b") || segment.includes("\u0003")) return false;
|
|
116
|
+
let accepted = false;
|
|
117
|
+
const normalized = segment.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
118
|
+
for (const ch of Array.from(normalized)) {
|
|
119
|
+
if (ch === "\n") {
|
|
120
|
+
if (state.partial.length > 0) accepted = true;
|
|
121
|
+
state.pendingLines.push(state.partial);
|
|
122
|
+
state.partial = "";
|
|
123
|
+
} else if (ch === "\u007f" || ch === "\b") {
|
|
124
|
+
const chars = Array.from(state.partial);
|
|
125
|
+
chars.pop();
|
|
126
|
+
state.partial = chars.join("");
|
|
127
|
+
accepted = true;
|
|
128
|
+
} else if (ch === "\t" || ch >= " ") {
|
|
129
|
+
state.partial += ch;
|
|
130
|
+
accepted = true;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return accepted;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Pasted body: pure DATA — newlines split commands into pastedLines, the trailing
|
|
137
|
+
* partial stays editable, and control bytes (incl. any stray ESC from copied ANSI
|
|
138
|
+
* text) are dropped instead of being interpreted as keystrokes. */
|
|
139
|
+
function feedPasteBody(state: PromptInputQueue, body: string): boolean {
|
|
140
|
+
if (!body) return false;
|
|
141
|
+
let accepted = false;
|
|
142
|
+
const normalized = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
143
|
+
for (const ch of Array.from(normalized)) {
|
|
144
|
+
if (ch === "\n") {
|
|
145
|
+
state.pastedLines.push(state.partial);
|
|
146
|
+
state.partial = "";
|
|
147
|
+
accepted = true;
|
|
148
|
+
} else if (ch === "\t" || ch >= " ") {
|
|
149
|
+
state.partial += ch;
|
|
150
|
+
accepted = true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return accepted;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function queuePromptInputChunk(state: PromptInputQueue, chunk: string): boolean {
|
|
157
|
+
if (!chunk) return false;
|
|
158
|
+
let accepted = false;
|
|
159
|
+
let rest = chunk;
|
|
160
|
+
while (rest.length > 0) {
|
|
161
|
+
if (state.inPaste) {
|
|
162
|
+
const end = rest.indexOf(PASTE_END);
|
|
163
|
+
const body = end === -1 ? rest : rest.slice(0, end);
|
|
164
|
+
if (end !== -1) state.inPaste = false;
|
|
165
|
+
rest = end === -1 ? "" : rest.slice(end + PASTE_END.length);
|
|
166
|
+
if (feedPasteBody(state, body)) accepted = true;
|
|
167
|
+
} else {
|
|
168
|
+
const start = rest.indexOf(PASTE_START);
|
|
169
|
+
const plain = start === -1 ? rest : rest.slice(0, start);
|
|
170
|
+
if (start !== -1) state.inPaste = true;
|
|
171
|
+
rest = start === -1 ? "" : rest.slice(start + PASTE_START.length);
|
|
172
|
+
if (feedTypedSegment(state, plain)) accepted = true;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return accepted;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Live-turn prompt capture: printable input edits the SAME next-prompt line the
|
|
179
|
+
* idle footer will show after the turn finishes. Enter does NOT promote a hidden
|
|
180
|
+
* queue entry; it merely marks the current line as ready, so the existing input
|
|
181
|
+
* box stays the single source of truth and the user presses Enter once more at
|
|
182
|
+
* the real prompt to run it. */
|
|
183
|
+
function feedLivePromptSegment(state: PromptInputQueue, segment: string): boolean {
|
|
184
|
+
if (!segment || segment.includes("\u001b") || segment.includes("\u0003")) return false;
|
|
185
|
+
let accepted = false;
|
|
186
|
+
const normalized = segment.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
187
|
+
for (const ch of Array.from(normalized)) {
|
|
188
|
+
if (ch === "\n") {
|
|
189
|
+
accepted = true;
|
|
190
|
+
} else if (ch === "\u007f" || ch === "\b") {
|
|
191
|
+
const chars = Array.from(state.partial);
|
|
192
|
+
chars.pop();
|
|
193
|
+
state.partial = chars.join("");
|
|
194
|
+
accepted = true;
|
|
195
|
+
} else if (ch === "\t" || ch >= " ") {
|
|
196
|
+
state.partial += ch;
|
|
197
|
+
accepted = true;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return accepted;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function feedLivePromptPasteBody(state: PromptInputQueue, body: string): boolean {
|
|
204
|
+
if (!body) return false;
|
|
205
|
+
const normalized = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
206
|
+
const flattened = normalized.split("\n").map(part => part.trim()).filter(Boolean).join(" ");
|
|
207
|
+
if (!flattened) return false;
|
|
208
|
+
state.partial = state.partial ? `${state.partial} ${flattened}` : flattened;
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function captureLivePromptInputChunk(state: PromptInputQueue, chunk: string): boolean {
|
|
213
|
+
if (!chunk) return false;
|
|
214
|
+
let accepted = false;
|
|
215
|
+
let rest = chunk;
|
|
216
|
+
while (rest.length > 0) {
|
|
217
|
+
if (state.inPaste) {
|
|
218
|
+
const end = rest.indexOf(PASTE_END);
|
|
219
|
+
const body = end === -1 ? rest : rest.slice(0, end);
|
|
220
|
+
if (end !== -1) state.inPaste = false;
|
|
221
|
+
rest = end === -1 ? "" : rest.slice(end + PASTE_END.length);
|
|
222
|
+
if (feedLivePromptPasteBody(state, body)) accepted = true;
|
|
223
|
+
} else {
|
|
224
|
+
const start = rest.indexOf(PASTE_START);
|
|
225
|
+
const plain = start === -1 ? rest : rest.slice(0, start);
|
|
226
|
+
if (start !== -1) state.inPaste = true;
|
|
227
|
+
rest = start === -1 ? "" : rest.slice(start + PASTE_START.length);
|
|
228
|
+
if (feedLivePromptSegment(state, plain)) accepted = true;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return accepted;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* TTY "new input first" contract: fold any queued FULL lines (stray
|
|
236
|
+
* Enter-terminated buffer noise, or older persisted queues) into the editable
|
|
237
|
+
* prompt prefill instead of leaving them to auto-execute as the next prompt.
|
|
238
|
+
* Without this, stale queued lines ran BEFORE the user's fresh input — jeo
|
|
239
|
+
* appeared to "continue the previous work first". Returns the number of lines
|
|
240
|
+
* folded. Pure over the queue object — piped/non-TTY callers must NOT use this
|
|
241
|
+
* (scripted stdin relies on in-order line execution).
|
|
242
|
+
*/
|
|
243
|
+
export function restoreQueuedLinesToPrefill(state: PromptInputQueue): number {
|
|
244
|
+
const lines = state.pendingLines.splice(0, state.pendingLines.length).map(l => l.trim()).filter(Boolean);
|
|
245
|
+
if (lines.length === 0) return 0;
|
|
246
|
+
const restored = lines.join(" ");
|
|
247
|
+
state.partial = state.partial ? `${restored} ${state.partial}`.trim() : restored;
|
|
248
|
+
return lines.length;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function createInFlightAbortHarness(opts: AbortHarnessOptions = {}): InFlightAbortHarness {
|
|
252
|
+
const controller = opts.controller ?? new AbortController();
|
|
253
|
+
const stdin = opts.stdin ?? process.stdin;
|
|
254
|
+
const captureEsc = opts.captureEsc === true && !!stdin.isTTY;
|
|
255
|
+
const wasRaw = !!stdin.isRaw;
|
|
256
|
+
let rawChanged = false;
|
|
257
|
+
|
|
258
|
+
const abortNow = (message: string) => {
|
|
259
|
+
if (controller.signal.aborted) return false;
|
|
260
|
+
opts.onAbortNotice?.(message);
|
|
261
|
+
controller.abort();
|
|
262
|
+
return true;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const handleSigint = () => {
|
|
266
|
+
if (!controller.signal.aborted) controller.abort();
|
|
267
|
+
opts.onHardExit?.();
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const handleData = (chunk: string | Uint8Array) => {
|
|
271
|
+
if (!captureEsc || controller.signal.aborted) return;
|
|
272
|
+
const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
|
273
|
+
if (text.includes(PASTE_START) || opts.pasteActive?.()) {
|
|
274
|
+
opts.onBufferedInput?.(text);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (text === "\u000f") {
|
|
278
|
+
opts.onDetailKey?.();
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (text === "\u001b[A") { opts.onScrollKey?.(-1, false); return; }
|
|
282
|
+
if (text === "\u001b[B") { opts.onScrollKey?.(1, false); return; }
|
|
283
|
+
if (text === "\u001b[5~") { opts.onScrollKey?.(-1, true); return; }
|
|
284
|
+
if (text === "\u001b[6~") { opts.onScrollKey?.(1, true); return; }
|
|
285
|
+
const escAt = text.indexOf("\u001b");
|
|
286
|
+
const sigintAt = text.indexOf("\u0003");
|
|
287
|
+
const controlAt =
|
|
288
|
+
escAt === -1 ? sigintAt :
|
|
289
|
+
sigintAt === -1 ? escAt :
|
|
290
|
+
Math.min(escAt, sigintAt);
|
|
291
|
+
if (controlAt >= 0) {
|
|
292
|
+
const printablePrefix = text.slice(0, controlAt);
|
|
293
|
+
if (printablePrefix) opts.onBufferedInput?.(printablePrefix);
|
|
294
|
+
if (text[controlAt] === "\u0003") {
|
|
295
|
+
handleSigint();
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (text === "\u001b") {
|
|
299
|
+
abortNow("ESC pressed — cancelling current run…");
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
opts.onNoise?.();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
opts.onBufferedInput?.(text);
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
process.on("SIGINT", handleSigint);
|
|
309
|
+
if (captureEsc) {
|
|
310
|
+
stdin.on("data", handleData);
|
|
311
|
+
if (stdin.setRawMode && !wasRaw) {
|
|
312
|
+
stdin.setRawMode(true);
|
|
313
|
+
rawChanged = true;
|
|
314
|
+
}
|
|
315
|
+
stdin.resume?.();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
controller,
|
|
320
|
+
handleSigint,
|
|
321
|
+
handleData,
|
|
322
|
+
dispose() {
|
|
323
|
+
process.removeListener("SIGINT", handleSigint);
|
|
324
|
+
if (captureEsc) {
|
|
325
|
+
stdin.off("data", handleData);
|
|
326
|
+
if (rawChanged) stdin.setRawMode?.(false);
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
}
|