jeo-code 0.5.16 → 0.6.1
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 +10 -2
- package/README.ko.md +10 -2
- package/README.md +10 -2
- package/README.zh.md +10 -2
- package/package.json +1 -1
- package/src/agent/input-history.ts +79 -0
- package/src/agent/tool-schemas.ts +14 -0
- package/src/ai/providers/anthropic.ts +39 -41
- package/src/ai/providers/antigravity.ts +6 -0
- package/src/ai/providers/openai-responses.ts +17 -14
- package/src/ai/providers/openai.ts +3 -10
- package/src/commands/launch.ts +164 -27
- package/src/commands/update.ts +47 -8
- package/src/skills/catalog.ts +9 -2
- package/src/tui/app.ts +62 -11
- package/src/tui/components/transcript.ts +23 -13
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.1] - 2026-06-16
|
|
10
|
+
_Live reasoning progress (no more frozen "calling model"), thinking-level fixes for Anthropic/Antigravity, and input-box/Ctrl+O TUI fixes._
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Live reasoning progress.** Codex/OpenAI reasoning models now stream their thinking into the live frame (`reasoning.summary: "auto"` + `response.reasoning*.delta` events surfaced via `onReasoning`), and the status row reads `reasoning (model)…` / `thinking — reasoning, no token stream yet…` after a silent wait instead of a frozen `calling model (Ns)…`.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- Thinking level is now applied to the **Anthropic and Antigravity** providers (it was a silent no-op there).
|
|
17
|
+
- The **input box + caret stay in place after running a command** — no more vanishing box / caret parked at the reservation top.
|
|
18
|
+
- **Skill runs render a compact `[skill]` card** instead of dumping the injected `SKILL.md` into a user box.
|
|
19
|
+
- **Ctrl+O fold toggle** + incremental session durability across interruption.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- Trimmed `fastThinkingLevelForModel` fallback to the real gap (ponytail pass); added a usage guide + demo video, linked from all READMEs.
|
|
23
|
+
|
|
24
|
+
## [0.6.0] - 2026-06-16
|
|
25
|
+
_TUI quality of life: durable input history (↑ recalls past queries across launches), clean `/resume` rendering, and a scrollable mid-turn Ctrl+O panel._
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
- **Durable input history.** ↑/↓ at the prompt now recall "이전에 사용한 쿼리" across launches, not just lines typed in the current run: submitted prompts persist to a per-workspace `.jeo/input-history` file (deduped, capped, best-effort) and hydrate readline's ring on the next launch. Composes with `/resume`, which already seeds the resumed session's own prompts into the ring. New `src/agent/input-history.ts` (`loadInputHistory`/`appendInputHistory`).
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
- **`/resume` dumped raw JSON and broke the TUI.** When a resumed session's assistant turn was stored as a ```json-fenced (or reasoning-decorated) tool call, the transcript renderer's naive `JSON.parse` failed and dumped the raw JSON block into the screen. `formatTranscript` now uses the engine's robust extractor (`tryExtractJsonObject`) and only treats a message as a tool call when it actually begins with `{` (after stripping a leading fence) — so fenced/decorated tool calls render as proper `✔ title` cards, prose that merely contains JSON stays prose, and no raw JSON ever leaks into the resumed view.
|
|
32
|
+
- **Ctrl+O while a turn runs cropped the detail panel** at "… N more line(s)", so a long reply / tool output (especially CJK) couldn't be read past the first screenful. The live panel now WINDOWS the content with `↑ N more above` / `↓ N more below` counters and is scrollable with ↑/↓ and PgUp/PgDn — every line is reachable, nothing is dropped, and short content still renders as a plain non-scrollable panel. The in-flight key harness routes arrow/PageUp/PageDown to a new `onScrollKey` hook (a no-op when the panel is closed, so those keys stay inert otherwise); `LaunchTui.scrollDetail()` clamps within `[0, max]` and guarantees the last line is reachable. Mirrors the 0.5.16 idle-prompt Ctrl+O fix for the mid-turn path.
|
|
33
|
+
|
|
9
34
|
## [0.5.16] - 2026-06-16
|
|
10
35
|
_`/resume` and Ctrl+O no longer corrupt the TUI — clean screen restore + scrollback expand._
|
|
11
36
|
|
package/README.ja.md
CHANGED
|
@@ -28,6 +28,14 @@
|
|
|
28
28
|
|
|
29
29
|
リポジトリ内で `jeo` を実行すると、ファイルを読み・編集し・コマンドを実行してタスクを完了まで進めます — 全ステップがスクロールバック親和なインライン TUI でライブ配信されます。
|
|
30
30
|
|
|
31
|
+
## ドキュメント
|
|
32
|
+
|
|
33
|
+
📖 **[使い方ガイド](docs/usage-guide.md)** — インストール、TUI操作(↑履歴、Ctrl+O、`!`シェル)、スラッシュコマンド、`/resume`、スペックファーストワークフローをデモ動画付きで解説。
|
|
34
|
+
|
|
35
|
+
<video src="https://raw.githubusercontent.com/akillness/jeo-code/main/docs/jeo-code-promo.mp4" controls muted playsinline width="100%"></video>
|
|
36
|
+
|
|
37
|
+
> インライン再生されない場合は ▶ [デモ動画を再生/ダウンロード](docs/jeo-code-promo.mp4)。
|
|
38
|
+
|
|
31
39
|
## ハイライト
|
|
32
40
|
|
|
33
41
|
- **マルチプロバイダ・単一ループ** — Anthropic / OpenAI(+Codex) / Gemini / Antigravity / Ollama を均一な JSON ツールループで。入力欄から OAuth ログイン(`/provider login`)、モデル選択は即座にデフォルトとして永続化。
|
|
@@ -150,11 +158,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
150
158
|
## 変更履歴 (Changelog)
|
|
151
159
|
|
|
152
160
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
161
|
+
- **[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.
|
|
162
|
+
- **[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.
|
|
153
163
|
- **[0.5.16]** (2026-06-16) — `/resume` and Ctrl+O no longer corrupt the TUI — clean screen restore + scrollback expand.
|
|
154
164
|
- **[0.5.15]** (2026-06-16) — `jeo update` now actually upgrades — bare command installs the latest release instead of just printing a manual command.
|
|
155
165
|
- **[0.5.14]** (2026-06-16) — `jeo --tmux` live-verification harness — repeatable stability + behavior checks.
|
|
156
|
-
- **[0.5.13]** (2026-06-15) — Workflow `/` commands actually run — `/deep-interview`, `/team`, `/ultragoal`, `/ralplan` dispatch by name.
|
|
157
|
-
- **[0.5.12]** (2026-06-15) — Yellow status animation while a process runs, and elapsed `(Nms)` on every completed tool card.
|
|
158
166
|
|
|
159
167
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
160
168
|
<!-- CHANGELOG:END -->
|
package/README.ko.md
CHANGED
|
@@ -28,6 +28,14 @@
|
|
|
28
28
|
|
|
29
29
|
저장소 안에서 `jeo`를 실행하면 파일을 읽고, 수정하고, 명령을 실행하며 작업을 완료까지 끌고 갑니다 — 모든 스텝이 스크롤백 친화적인 인라인 TUI로 실시간 스트리밍됩니다.
|
|
30
30
|
|
|
31
|
+
## 문서
|
|
32
|
+
|
|
33
|
+
📖 **[사용 가이드](docs/usage-guide.md)** — 설치, TUI 조작(↑ 이전 쿼리, Ctrl+O, `!` 셸), 슬래시 명령, `/resume`, 스펙 우선 워크플로를 데모 영상과 함께 설명합니다.
|
|
34
|
+
|
|
35
|
+
<video src="https://raw.githubusercontent.com/akillness/jeo-code/main/docs/jeo-code-promo.mp4" controls muted playsinline width="100%"></video>
|
|
36
|
+
|
|
37
|
+
> 인라인 재생이 안 되면 ▶ [데모 영상 재생/다운로드](docs/jeo-code-promo.mp4).
|
|
38
|
+
|
|
31
39
|
## 하이라이트
|
|
32
40
|
|
|
33
41
|
- **멀티 프로바이더, 단일 루프** — Anthropic / OpenAI(+Codex) / Gemini / Antigravity / Ollama를 균일한 JSON 도구 루프로. 입력창에서 바로 OAuth 로그인(`/provider login`), 모델 선택은 즉시 기본값으로 영속.
|
|
@@ -150,11 +158,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
150
158
|
## 변경 이력 (Changelog)
|
|
151
159
|
|
|
152
160
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
161
|
+
- **[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.
|
|
162
|
+
- **[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.
|
|
153
163
|
- **[0.5.16]** (2026-06-16) — `/resume` and Ctrl+O no longer corrupt the TUI — clean screen restore + scrollback expand.
|
|
154
164
|
- **[0.5.15]** (2026-06-16) — `jeo update` now actually upgrades — bare command installs the latest release instead of just printing a manual command.
|
|
155
165
|
- **[0.5.14]** (2026-06-16) — `jeo --tmux` live-verification harness — repeatable stability + behavior checks.
|
|
156
|
-
- **[0.5.13]** (2026-06-15) — Workflow `/` commands actually run — `/deep-interview`, `/team`, `/ultragoal`, `/ralplan` dispatch by name.
|
|
157
|
-
- **[0.5.12]** (2026-06-15) — Yellow status animation while a process runs, and elapsed `(Nms)` on every completed tool card.
|
|
158
166
|
|
|
159
167
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
160
168
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -28,6 +28,14 @@
|
|
|
28
28
|
|
|
29
29
|
Run `jeo` inside a repository and it reads files, edits them, runs commands, and drives the task to completion — streaming every step live in an inline, scrollback-friendly TUI.
|
|
30
30
|
|
|
31
|
+
## Documentation
|
|
32
|
+
|
|
33
|
+
📖 **[Usage guide](docs/usage-guide.md)** — install, TUI controls (↑ recall, Ctrl+O, `!` shell), slash commands, `/resume`, and the spec-first workflow, with a demo video.
|
|
34
|
+
|
|
35
|
+
<video src="https://raw.githubusercontent.com/akillness/jeo-code/main/docs/jeo-code-promo.mp4" controls muted playsinline width="100%"></video>
|
|
36
|
+
|
|
37
|
+
> Demo not playing inline? ▶ [Play / download the demo video](docs/jeo-code-promo.mp4).
|
|
38
|
+
|
|
31
39
|
## Highlights
|
|
32
40
|
|
|
33
41
|
- **Multi-provider, one loop** — Anthropic / OpenAI (+Codex) / Gemini / Antigravity / Ollama behind a uniform JSON tool loop. OAuth login from the input box (`/provider login`), every model pick persists as the new default.
|
|
@@ -150,11 +158,11 @@ Required npm token permissions (repository secret `NPM_TOKEN`):
|
|
|
150
158
|
## Changelog
|
|
151
159
|
|
|
152
160
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
161
|
+
- **[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.
|
|
162
|
+
- **[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.
|
|
153
163
|
- **[0.5.16]** (2026-06-16) — `/resume` and Ctrl+O no longer corrupt the TUI — clean screen restore + scrollback expand.
|
|
154
164
|
- **[0.5.15]** (2026-06-16) — `jeo update` now actually upgrades — bare command installs the latest release instead of just printing a manual command.
|
|
155
165
|
- **[0.5.14]** (2026-06-16) — `jeo --tmux` live-verification harness — repeatable stability + behavior checks.
|
|
156
|
-
- **[0.5.13]** (2026-06-15) — Workflow `/` commands actually run — `/deep-interview`, `/team`, `/ultragoal`, `/ralplan` dispatch by name.
|
|
157
|
-
- **[0.5.12]** (2026-06-15) — Yellow status animation while a process runs, and elapsed `(Nms)` on every completed tool card.
|
|
158
166
|
|
|
159
167
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
160
168
|
<!-- CHANGELOG:END -->
|
package/README.zh.md
CHANGED
|
@@ -28,6 +28,14 @@
|
|
|
28
28
|
|
|
29
29
|
在仓库内运行 `jeo`,它会读取文件、编辑代码、执行命令,并把任务推进到完成 — 每一步都通过滚动友好的内联 TUI 实时呈现。
|
|
30
30
|
|
|
31
|
+
## 文档
|
|
32
|
+
|
|
33
|
+
📖 **[使用指南](docs/usage-guide.md)** — 安装、TUI 操作(↑ 历史、Ctrl+O、`!` shell)、斜杠命令、`/resume`、规格优先工作流,附演示视频。
|
|
34
|
+
|
|
35
|
+
<video src="https://raw.githubusercontent.com/akillness/jeo-code/main/docs/jeo-code-promo.mp4" controls muted playsinline width="100%"></video>
|
|
36
|
+
|
|
37
|
+
> 无法内联播放?▶ [播放/下载演示视频](docs/jeo-code-promo.mp4)。
|
|
38
|
+
|
|
31
39
|
## 亮点
|
|
32
40
|
|
|
33
41
|
- **多提供商、单一循环** — Anthropic / OpenAI(+Codex) / Gemini / Antigravity / Ollama 统一在一个 JSON 工具循环中。输入框内直接 OAuth 登录(`/provider login`),模型选择即刻持久化为默认值。
|
|
@@ -150,11 +158,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
150
158
|
## 更新日志 (Changelog)
|
|
151
159
|
|
|
152
160
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
161
|
+
- **[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.
|
|
162
|
+
- **[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.
|
|
153
163
|
- **[0.5.16]** (2026-06-16) — `/resume` and Ctrl+O no longer corrupt the TUI — clean screen restore + scrollback expand.
|
|
154
164
|
- **[0.5.15]** (2026-06-16) — `jeo update` now actually upgrades — bare command installs the latest release instead of just printing a manual command.
|
|
155
165
|
- **[0.5.14]** (2026-06-16) — `jeo --tmux` live-verification harness — repeatable stability + behavior checks.
|
|
156
|
-
- **[0.5.13]** (2026-06-15) — Workflow `/` commands actually run — `/deep-interview`, `/team`, `/ultragoal`, `/ralplan` dispatch by name.
|
|
157
|
-
- **[0.5.12]** (2026-06-15) — Yellow status animation while a process runs, and elapsed `(Nms)` on every completed tool card.
|
|
158
166
|
|
|
159
167
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
160
168
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-launch input-history persistence for the interactive prompt.
|
|
3
|
+
*
|
|
4
|
+
* readline only remembers lines typed in the CURRENT run, so ↑ at the prompt
|
|
5
|
+
* recalled nothing on a fresh launch (and only this-run prompts otherwise). This
|
|
6
|
+
* persists submitted prompts to a per-workspace file so ↑ recalls "previously
|
|
7
|
+
* used queries" across launches — the same recall a shell gives you — and it
|
|
8
|
+
* composes with `/resume`'s in-session seeding (gjc-style durable input history).
|
|
9
|
+
*
|
|
10
|
+
* Pure filesystem helpers, kept tiny and best-effort: a read/write failure never
|
|
11
|
+
* breaks the prompt. The file is newline-delimited, oldest→newest on disk.
|
|
12
|
+
*/
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
|
|
16
|
+
const FILE = "input-history";
|
|
17
|
+
/** How many recent entries to hydrate into readline on launch. */
|
|
18
|
+
export const HISTORY_LOAD_LIMIT = 200;
|
|
19
|
+
/** Hard cap on the on-disk file so it can't grow without bound. */
|
|
20
|
+
export const HISTORY_FILE_CAP = 1000;
|
|
21
|
+
/** Skip persisting pathological single lines (giant pastes etc.). */
|
|
22
|
+
const MAX_ENTRY_LEN = 2000;
|
|
23
|
+
|
|
24
|
+
function jeoDir(cwd: string): string {
|
|
25
|
+
return path.join(cwd, ".jeo");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function inputHistoryPath(cwd: string): string {
|
|
29
|
+
return path.join(jeoDir(cwd), FILE);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readLines(cwd: string): string[] {
|
|
33
|
+
try {
|
|
34
|
+
return fs
|
|
35
|
+
.readFileSync(inputHistoryPath(cwd), "utf-8")
|
|
36
|
+
.split("\n")
|
|
37
|
+
.map(l => l.replace(/\s+$/, ""))
|
|
38
|
+
.filter(Boolean);
|
|
39
|
+
} catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Load recent prompts NEWEST-FIRST, deduplicated, for readline's `rl.history`
|
|
46
|
+
* (which is itself newest-first). At most `limit` entries; never throws.
|
|
47
|
+
*/
|
|
48
|
+
export function loadInputHistory(cwd: string, limit = HISTORY_LOAD_LIMIT): string[] {
|
|
49
|
+
const lines = readLines(cwd); // oldest → newest
|
|
50
|
+
const seen = new Set<string>();
|
|
51
|
+
const out: string[] = [];
|
|
52
|
+
for (let i = lines.length - 1; i >= 0 && out.length < limit; i--) {
|
|
53
|
+
const line = lines[i]!;
|
|
54
|
+
if (seen.has(line)) continue;
|
|
55
|
+
seen.add(line);
|
|
56
|
+
out.push(line); // walking backward → already newest-first
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Append one submitted prompt (best-effort, never throws). Skips blanks, the
|
|
63
|
+
* immediate duplicate of the last entry, multi-line/over-long pastes, and trims
|
|
64
|
+
* the file to `cap` lines so it stays bounded.
|
|
65
|
+
*/
|
|
66
|
+
export function appendInputHistory(cwd: string, line: string, cap = HISTORY_FILE_CAP): void {
|
|
67
|
+
try {
|
|
68
|
+
const entry = line.trim();
|
|
69
|
+
if (!entry || entry.includes("\n") || entry.length > MAX_ENTRY_LEN) return;
|
|
70
|
+
const existing = readLines(cwd);
|
|
71
|
+
if (existing.length > 0 && existing[existing.length - 1] === entry) return;
|
|
72
|
+
existing.push(entry);
|
|
73
|
+
const trimmed = existing.length > cap ? existing.slice(existing.length - cap) : existing;
|
|
74
|
+
fs.mkdirSync(jeoDir(cwd), { recursive: true });
|
|
75
|
+
fs.writeFileSync(inputHistoryPath(cwd), `${trimmed.join("\n")}\n`, "utf-8");
|
|
76
|
+
} catch {
|
|
77
|
+
/* best-effort: history persistence must never break the prompt */
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -130,3 +130,17 @@ export function serializeToolCalls(calls: { tool: string; arguments: Record<stri
|
|
|
130
130
|
export function normalizeNativeToolName(name: string): string {
|
|
131
131
|
return (name ?? "").replace(/^default_api\s*[.:]\s*/, "").trim();
|
|
132
132
|
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Re-serialize a streamed native tool-call accumulator (name + raw-JSON-args string per
|
|
136
|
+
* output index — the shape every streaming adapter builds) into the engine's canonical
|
|
137
|
+
* JSON. Bad arg JSON degrades to `{}` rather than dropping the call. Returns null when empty.
|
|
138
|
+
*/
|
|
139
|
+
export function serializeAccumulatedToolCalls(acc: Map<number, { name: string; args: string }>): string | null {
|
|
140
|
+
const calls = [...acc.values()].map(b => {
|
|
141
|
+
let args: Record<string, unknown> = {};
|
|
142
|
+
try { args = b.args ? JSON.parse(b.args) : {}; } catch { args = {}; }
|
|
143
|
+
return { tool: b.name, arguments: args };
|
|
144
|
+
});
|
|
145
|
+
return serializeToolCalls(calls);
|
|
146
|
+
}
|
|
@@ -3,6 +3,7 @@ import type { Credential } from "../../auth";
|
|
|
3
3
|
import type { CallOptions, Message, ProviderAdapter } from "../types";
|
|
4
4
|
import { readSse } from "../sse";
|
|
5
5
|
import { ProviderHttpError, parseRetryAfter, parseRetryFromBody, providerHttpError } from "./errors";
|
|
6
|
+
import { serializeToolCalls, serializeAccumulatedToolCalls } from "../../agent/tool-schemas";
|
|
6
7
|
|
|
7
8
|
const ANTHROPIC_URL = "https://api.anthropic.com/v1/messages";
|
|
8
9
|
|
|
@@ -71,6 +72,18 @@ function anthropicSystemBlocks(
|
|
|
71
72
|
return blocks;
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
/** Anthropic extended-thinking budget by reasoning effort (kept under max_tokens). Off for
|
|
76
|
+
* low/minimal/unset effort so /fast and minimal thinking stay non-thinking (cheaper/faster). */
|
|
77
|
+
function anthropicThinkingBudget(effort: CallOptions["reasoningEffort"], maxTokens: number): number | undefined {
|
|
78
|
+
let budget: number;
|
|
79
|
+
switch (effort) {
|
|
80
|
+
case "medium": budget = 4096; break;
|
|
81
|
+
case "high": budget = 10000; break;
|
|
82
|
+
default: return undefined;
|
|
83
|
+
}
|
|
84
|
+
return Math.min(budget, Math.max(1024, maxTokens - 1024));
|
|
85
|
+
}
|
|
86
|
+
|
|
74
87
|
export function anthropicPayload(
|
|
75
88
|
messages: Message[],
|
|
76
89
|
options: CallOptions,
|
|
@@ -108,13 +121,24 @@ export function anthropicPayload(
|
|
|
108
121
|
last.content[last.content.length - 1] = { ...tail, cache_control: { type: "ephemeral" } };
|
|
109
122
|
}
|
|
110
123
|
}
|
|
124
|
+
const maxTokens = options.maxTokens ?? 4000;
|
|
125
|
+
const thinkingBudget = anthropicThinkingBudget(options.reasoningEffort, maxTokens);
|
|
111
126
|
const payload: Record<string, unknown> = {
|
|
112
127
|
model,
|
|
113
128
|
messages: anthropicMessages,
|
|
114
|
-
max_tokens
|
|
129
|
+
// Extended thinking requires max_tokens strictly above the thinking budget.
|
|
130
|
+
max_tokens: thinkingBudget !== undefined ? Math.max(maxTokens, thinkingBudget + 1024) : maxTokens,
|
|
115
131
|
};
|
|
116
132
|
if (credential.kind === "oauth") payload.metadata = { user_id: createClaudeCloakingUserId() };
|
|
117
|
-
if (
|
|
133
|
+
if (thinkingBudget !== undefined) {
|
|
134
|
+
// Apply the thinking level: enable Claude extended thinking (the interleaved-thinking
|
|
135
|
+
// beta is already in the headers). Extended thinking forbids a custom temperature, so
|
|
136
|
+
// temperature is only set on the non-thinking path. Previously the thinking level only
|
|
137
|
+
// changed max_tokens and never reached Claude as actual reasoning depth.
|
|
138
|
+
payload.thinking = { type: "enabled", budget_tokens: thinkingBudget };
|
|
139
|
+
} else if (includeTemperature && options.temperature !== undefined) {
|
|
140
|
+
payload.temperature = options.temperature;
|
|
141
|
+
}
|
|
118
142
|
if (options.tools?.length) {
|
|
119
143
|
// NATIVE tool-calling: declare jeo's tools as Anthropic functions. tool_choice
|
|
120
144
|
// "auto" keeps prose-salvage reachable and lets the model call `done` (declared as
|
|
@@ -198,25 +222,6 @@ function emptyCompletionError(stopReason: string | undefined): Error {
|
|
|
198
222
|
return new Error(`Anthropic returned no content${stopReason ? ` (stop_reason=${stopReason})` : ""}${hint}.`);
|
|
199
223
|
}
|
|
200
224
|
|
|
201
|
-
/**
|
|
202
|
-
* Re-serialize Anthropic native `tool_use` content block(s) into the engine's canonical
|
|
203
|
-
* JSON string — the linchpin of the adapter-internal-serialization design: the engine,
|
|
204
|
-
* anti-spin guards, and done-gate keep consuming the SAME {"tool":...}/{"tools":[...]}
|
|
205
|
-
* shape they parse from the JSON-in-prose path. A batched `done` is coalesced to a single
|
|
206
|
-
* done envelope (the engine rejects done-in-batch). Returns null when there is no tool_use.
|
|
207
|
-
*/
|
|
208
|
-
function serializeAnthropicToolUse(
|
|
209
|
-
content: { type: string; name?: string; input?: unknown }[],
|
|
210
|
-
): string | null {
|
|
211
|
-
const calls = content
|
|
212
|
-
.filter(c => c.type === "tool_use" && typeof c.name === "string")
|
|
213
|
-
.map(c => ({ tool: c.name as string, arguments: (c.input ?? {}) as Record<string, unknown> }));
|
|
214
|
-
if (calls.length === 0) return null;
|
|
215
|
-
const done = calls.find(c => c.tool === "done");
|
|
216
|
-
if (done) return JSON.stringify(done);
|
|
217
|
-
if (calls.length === 1) return JSON.stringify(calls[0]);
|
|
218
|
-
return JSON.stringify({ tools: calls });
|
|
219
|
-
}
|
|
220
225
|
export const anthropicAdapter: ProviderAdapter = {
|
|
221
226
|
name: "anthropic",
|
|
222
227
|
supportsNativeTools: true,
|
|
@@ -225,7 +230,11 @@ export const anthropicAdapter: ProviderAdapter = {
|
|
|
225
230
|
const result = (await response.json()) as { content: { type: string; text?: string; name?: string; input?: unknown }[]; stop_reason?: string; usage?: AnthropicUsage };
|
|
226
231
|
if (result.usage) options.onUsage?.({ inputTokens: totalInputTokens(result.usage), outputTokens: result.usage.output_tokens });
|
|
227
232
|
// Prefer a native tool call (re-serialized to canonical JSON) over any stray text.
|
|
228
|
-
const toolCall =
|
|
233
|
+
const toolCall = serializeToolCalls(
|
|
234
|
+
result.content
|
|
235
|
+
.filter(c => c.type === "tool_use" && typeof c.name === "string")
|
|
236
|
+
.map(c => ({ tool: c.name as string, arguments: (c.input ?? {}) as Record<string, unknown> })),
|
|
237
|
+
);
|
|
229
238
|
if (toolCall) return toolCall;
|
|
230
239
|
const text = result.content.find(c => c.type === "text")?.text ?? "";
|
|
231
240
|
if (!text) throw emptyCompletionError(result.stop_reason);
|
|
@@ -240,13 +249,13 @@ export const anthropicAdapter: ProviderAdapter = {
|
|
|
240
249
|
// Native tool_use streams as content_block_start (name) + input_json_delta fragments,
|
|
241
250
|
// never as text_delta — accumulate per block index, then re-serialize to canonical
|
|
242
251
|
// JSON and yield it once at the end (concatenation still equals call()).
|
|
243
|
-
const toolBlocks = new Map<number, { name: string;
|
|
252
|
+
const toolBlocks = new Map<number, { name: string; args: string }>();
|
|
244
253
|
for await (const data of readSse(response.body)) {
|
|
245
254
|
let evt: {
|
|
246
255
|
type?: string;
|
|
247
256
|
index?: number;
|
|
248
257
|
content_block?: { type?: string; name?: string };
|
|
249
|
-
delta?: { type?: string; text?: string; partial_json?: string; stop_reason?: string };
|
|
258
|
+
delta?: { type?: string; text?: string; partial_json?: string; thinking?: string; stop_reason?: string };
|
|
250
259
|
message?: { usage?: AnthropicUsage };
|
|
251
260
|
usage?: { output_tokens?: number };
|
|
252
261
|
};
|
|
@@ -256,13 +265,15 @@ export const anthropicAdapter: ProviderAdapter = {
|
|
|
256
265
|
continue;
|
|
257
266
|
}
|
|
258
267
|
if (evt.type === "content_block_start" && evt.content_block?.type === "tool_use" && typeof evt.index === "number") {
|
|
259
|
-
toolBlocks.set(evt.index, { name: evt.content_block.name ?? "",
|
|
268
|
+
toolBlocks.set(evt.index, { name: evt.content_block.name ?? "", args: "" });
|
|
260
269
|
} else if (evt.type === "content_block_delta" && evt.delta?.type === "input_json_delta" && typeof evt.index === "number") {
|
|
261
270
|
const b = toolBlocks.get(evt.index);
|
|
262
|
-
if (b) b.
|
|
271
|
+
if (b) b.args += evt.delta.partial_json ?? "";
|
|
263
272
|
} else if (evt.type === "content_block_delta" && evt.delta?.type === "text_delta" && evt.delta.text) {
|
|
264
273
|
yieldedAny = true;
|
|
265
274
|
yield evt.delta.text;
|
|
275
|
+
} else if (evt.type === "content_block_delta" && evt.delta?.type === "thinking_delta" && evt.delta.thinking) {
|
|
276
|
+
options.onReasoning?.(evt.delta.thinking);
|
|
266
277
|
} else if (evt.type === "message_start" && evt.message?.usage) {
|
|
267
278
|
// Cache only — usage is reported ONCE at message_delta so an accumulating
|
|
268
279
|
// sink can't double-count input (and a pre-first-chunk retry that replays
|
|
@@ -273,21 +284,8 @@ export const anthropicAdapter: ProviderAdapter = {
|
|
|
273
284
|
if (evt.usage) options.onUsage?.({ inputTokens: cachedInput, outputTokens: evt.usage.output_tokens });
|
|
274
285
|
}
|
|
275
286
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
.map(b => {
|
|
279
|
-
let args: Record<string, unknown> = {};
|
|
280
|
-
try { args = b.json ? JSON.parse(b.json) : {}; } catch { args = {}; }
|
|
281
|
-
return { tool: b.name, arguments: args };
|
|
282
|
-
})
|
|
283
|
-
.filter(c => c.tool);
|
|
284
|
-
if (calls.length > 0) {
|
|
285
|
-
const done = calls.find(c => c.tool === "done");
|
|
286
|
-
const envelope = done ? JSON.stringify(done) : calls.length === 1 ? JSON.stringify(calls[0]) : JSON.stringify({ tools: calls });
|
|
287
|
-
yieldedAny = true;
|
|
288
|
-
yield envelope;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
287
|
+
const envelope = serializeAccumulatedToolCalls(toolBlocks);
|
|
288
|
+
if (envelope) { yieldedAny = true; yield envelope; }
|
|
291
289
|
if (!yieldedAny) throw emptyCompletionError(stopReason);
|
|
292
290
|
},
|
|
293
291
|
};
|
|
@@ -4,6 +4,7 @@ import type { CallOptions, Message, ProviderAdapter } from "../types";
|
|
|
4
4
|
import { readSse } from "../sse";
|
|
5
5
|
import { providerHttpError } from "./errors";
|
|
6
6
|
import { serializeToolCalls } from "../../agent/tool-schemas";
|
|
7
|
+
import { geminiThinkingBudget } from "./gemini";
|
|
7
8
|
|
|
8
9
|
const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
|
|
9
10
|
const ANTIGRAVITY_SANDBOX_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
|
|
@@ -130,6 +131,11 @@ export function antigravityRequest(messages: Message[], options: CallOptions, cr
|
|
|
130
131
|
if (options.temperature !== undefined) generationConfig.temperature = options.temperature;
|
|
131
132
|
// Upstream Antigravity strips maxOutputTokens for non-Claude models; do the same.
|
|
132
133
|
if (model.toLowerCase().includes("claude")) generationConfig.maxOutputTokens = options.maxTokens ?? 4000;
|
|
134
|
+
// Apply the thinking level: antigravity serves Gemini models through CCA, so reuse the
|
|
135
|
+
// Gemini thinkingConfig budget (off at minimal, scaling with reasoning effort). Without
|
|
136
|
+
// this the thinking level only changed token budget, never actual reasoning depth.
|
|
137
|
+
const agThinkingBudget = geminiThinkingBudget(model, options.reasoningEffort);
|
|
138
|
+
if (agThinkingBudget !== undefined) generationConfig.thinkingConfig = { thinkingBudget: agThinkingBudget };
|
|
133
139
|
|
|
134
140
|
const request: Record<string, unknown> = {
|
|
135
141
|
contents: antigravityContents(messages),
|
|
@@ -14,7 +14,7 @@ import type { Credential } from "../../auth";
|
|
|
14
14
|
import type { CallOptions, Message } from "../types";
|
|
15
15
|
import { readSse } from "../sse";
|
|
16
16
|
import { providerHttpError } from "./errors";
|
|
17
|
-
import {
|
|
17
|
+
import { serializeAccumulatedToolCalls } from "../../agent/tool-schemas";
|
|
18
18
|
|
|
19
19
|
export const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
|
|
20
20
|
|
|
@@ -72,7 +72,9 @@ export function codexResponsesRequest(
|
|
|
72
72
|
// Map thinkingLevel → reasoning effort for Codex reasoning models (gjc parity).
|
|
73
73
|
// Drop out-of-enum values instead of forwarding them — the backend 400s on unknown efforts.
|
|
74
74
|
if (options.reasoningEffort && VALID_REASONING_EFFORTS.has(options.reasoningEffort)) {
|
|
75
|
-
|
|
75
|
+
// `summary: "auto"` makes the backend stream reasoning-summary deltas so the live
|
|
76
|
+
// frame can show the model's thinking instead of a frozen "calling model (Ns)…".
|
|
77
|
+
payload.reasoning = { effort: options.reasoningEffort, summary: "auto" };
|
|
76
78
|
}
|
|
77
79
|
const accountId = extractChatgptAccountId(token);
|
|
78
80
|
const headers: Record<string, string> = {
|
|
@@ -88,6 +90,9 @@ export function codexResponsesRequest(
|
|
|
88
90
|
|
|
89
91
|
export interface ResponsesEvent {
|
|
90
92
|
delta?: string;
|
|
93
|
+
/** Reasoning-summary text delta (`response.reasoning_summary_text.delta` and the
|
|
94
|
+
* Codex backend's `reasoning_text` variant) — streamed live as the model thinks. */
|
|
95
|
+
reasoningDelta?: string;
|
|
91
96
|
usage?: { inputTokens?: number; outputTokens?: number };
|
|
92
97
|
error?: string;
|
|
93
98
|
/** `response.incomplete` cause (e.g. max_output_tokens) — surfaced when the
|
|
@@ -125,6 +130,12 @@ export function parseResponsesEvent(data: string): ResponsesEvent {
|
|
|
125
130
|
return { toolCallArgsDelta: o.delta, toolCallIndex: o.output_index };
|
|
126
131
|
}
|
|
127
132
|
if (o.type === "response.output_text.delta" && typeof o.delta === "string") return { delta: o.delta };
|
|
133
|
+
// Reasoning-summary streaming: surface the model's thinking live. Accept the
|
|
134
|
+
// documented `response.reasoning_summary_text.delta` and the Codex backend's
|
|
135
|
+
// `response.reasoning_text.delta` (any reasoning*.delta variant) uniformly.
|
|
136
|
+
if (typeof o.delta === "string" && /^response\.reasoning[a-z_]*\.delta$/.test(o.type ?? "")) {
|
|
137
|
+
return { reasoningDelta: o.delta };
|
|
138
|
+
}
|
|
128
139
|
// `response.incomplete` (max_output_tokens / content filter) also carries usage — don't drop it.
|
|
129
140
|
if ((o.type === "response.completed" || o.type === "response.incomplete") && o.response?.usage) {
|
|
130
141
|
return {
|
|
@@ -154,16 +165,6 @@ function accumulateResponsesToolCall(acc: Map<number, { name: string; args: stri
|
|
|
154
165
|
}
|
|
155
166
|
}
|
|
156
167
|
|
|
157
|
-
/** Re-serialize accumulated Responses function calls into the engine's canonical JSON. */
|
|
158
|
-
function serializeResponsesToolCalls(acc: Map<number, { name: string; args: string }>): string | null {
|
|
159
|
-
if (acc.size === 0) return null;
|
|
160
|
-
const calls = [...acc.values()].map(b => {
|
|
161
|
-
let args: Record<string, unknown> = {};
|
|
162
|
-
try { args = b.args ? JSON.parse(b.args) : {}; } catch { args = {}; }
|
|
163
|
-
return { tool: b.name, arguments: args };
|
|
164
|
-
});
|
|
165
|
-
return serializeToolCalls(calls);
|
|
166
|
-
}
|
|
167
168
|
|
|
168
169
|
/** Round-5 #1: no-text completions surface their cause instead of returning "". */
|
|
169
170
|
function emptyCompletionError(reason: string | undefined): Error {
|
|
@@ -185,13 +186,14 @@ export async function codexResponsesCall(messages: Message[], options: CallOptio
|
|
|
185
186
|
for await (const data of readSse(response.body)) {
|
|
186
187
|
const ev = parseResponsesEvent(data);
|
|
187
188
|
if (ev.delta) out += ev.delta;
|
|
189
|
+
if (ev.reasoningDelta) options.onReasoning?.(ev.reasoningDelta);
|
|
188
190
|
accumulateResponsesToolCall(toolAcc, ev);
|
|
189
191
|
if (ev.usage) options.onUsage?.(ev.usage);
|
|
190
192
|
if (ev.incompleteReason) incompleteReason = ev.incompleteReason;
|
|
191
193
|
if (ev.error) throw new Error(`OpenAI Codex response failed: ${ev.error}`);
|
|
192
194
|
}
|
|
193
195
|
// Prefer a native tool call (re-serialized to canonical JSON) over any stray text.
|
|
194
|
-
const envelope =
|
|
196
|
+
const envelope = serializeAccumulatedToolCalls(toolAcc);
|
|
195
197
|
if (envelope) return envelope;
|
|
196
198
|
if (!out) throw emptyCompletionError(incompleteReason);
|
|
197
199
|
return out;
|
|
@@ -212,6 +214,7 @@ export async function* codexResponsesStream(
|
|
|
212
214
|
const toolAcc = new Map<number, { name: string; args: string }>();
|
|
213
215
|
for await (const data of readSse(response.body)) {
|
|
214
216
|
const ev = parseResponsesEvent(data);
|
|
217
|
+
if (ev.reasoningDelta) options.onReasoning?.(ev.reasoningDelta);
|
|
215
218
|
if (ev.delta) {
|
|
216
219
|
yieldedAny = true;
|
|
217
220
|
yield ev.delta;
|
|
@@ -222,7 +225,7 @@ export async function* codexResponsesStream(
|
|
|
222
225
|
if (ev.error) throw new Error(`OpenAI Codex response failed: ${ev.error}`);
|
|
223
226
|
}
|
|
224
227
|
// Native tool calls have no output_text deltas — yield the re-serialized envelope once.
|
|
225
|
-
const envelope =
|
|
228
|
+
const envelope = serializeAccumulatedToolCalls(toolAcc);
|
|
226
229
|
if (envelope) { yieldedAny = true; yield envelope; }
|
|
227
230
|
if (!yieldedAny) throw emptyCompletionError(incompleteReason);
|
|
228
231
|
}
|
|
@@ -3,7 +3,7 @@ import type { CallOptions, Message, ProviderAdapter } from "../types";
|
|
|
3
3
|
import { readSse } from "../sse";
|
|
4
4
|
import { providerHttpError } from "./errors";
|
|
5
5
|
import { codexResponsesCall, codexResponsesStream } from "./openai-responses";
|
|
6
|
-
import { serializeToolCalls } from "../../agent/tool-schemas";
|
|
6
|
+
import { serializeToolCalls, serializeAccumulatedToolCalls } from "../../agent/tool-schemas";
|
|
7
7
|
|
|
8
8
|
export function openaiRequest(messages: Message[], options: CallOptions, credential: Credential, stream: boolean): { url: string; headers: Record<string, string>; body: string } {
|
|
9
9
|
const model = options.model.startsWith("openai/") ? options.model.slice(7) : options.model;
|
|
@@ -129,15 +129,8 @@ export const openaiAdapter: ProviderAdapter = {
|
|
|
129
129
|
if (chunk.usage) options.onUsage?.({ inputTokens: chunk.usage.prompt_tokens, outputTokens: chunk.usage.completion_tokens });
|
|
130
130
|
}
|
|
131
131
|
// Native tool calls stream as tool_calls argument fragments — re-serialize once at end.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
let args: Record<string, unknown> = {};
|
|
135
|
-
try { args = b.args ? JSON.parse(b.args) : {}; } catch { args = {}; }
|
|
136
|
-
return { tool: b.name, arguments: args };
|
|
137
|
-
});
|
|
138
|
-
const envelope = serializeToolCalls(calls);
|
|
139
|
-
if (envelope) { yieldedAny = true; yield envelope; }
|
|
140
|
-
}
|
|
132
|
+
const envelope = serializeAccumulatedToolCalls(toolAcc);
|
|
133
|
+
if (envelope) { yieldedAny = true; yield envelope; }
|
|
141
134
|
if (!yieldedAny) throw emptyCompletionError(finishReason);
|
|
142
135
|
},
|
|
143
136
|
};
|
package/src/commands/launch.ts
CHANGED
|
@@ -63,6 +63,7 @@ import { renderStatusBar } from "../tui/components/status";
|
|
|
63
63
|
import { detectColorLevel, ColorLevel } from "../tui/components/color";
|
|
64
64
|
import { readClipboardImage } from "../util/clipboard-image";
|
|
65
65
|
import { formatTranscript } from "../tui/components/transcript";
|
|
66
|
+
import { loadInputHistory, appendInputHistory } from "../agent/input-history";
|
|
66
67
|
import type { ImageAttachment } from "../ai/types";
|
|
67
68
|
import { renderMarkdownTables } from "../tui/components/markdown-table";
|
|
68
69
|
|
|
@@ -141,6 +142,12 @@ function fastThinkingLevelForModel(modelId: string): ThinkLevel | undefined {
|
|
|
141
142
|
const supported = catalogMetadata(modelId)?.thinking ?? [];
|
|
142
143
|
if (supported.includes("minimal")) return "minimal";
|
|
143
144
|
if (supported.includes("low")) return "low";
|
|
145
|
+
// Fallback for the one thinking-capable family that misses a catalog entry in practice:
|
|
146
|
+
// the dynamic antigravity `gemini-3.x` variants (gemini-3.5-flash-low/-extra-low, …) the
|
|
147
|
+
// CCA backend serves but the static catalog can't enumerate. They apply a thinking budget,
|
|
148
|
+
// so /fast offers `minimal` as the fast level instead of reporting "unsupported". (openai
|
|
149
|
+
// reasoning models and *-thinking/-high/-low antigravity ids are already catalogued.)
|
|
150
|
+
if (/gemini-(2\.5|[3-9])/.test(modelId.toLowerCase())) return "minimal";
|
|
144
151
|
return undefined;
|
|
145
152
|
}
|
|
146
153
|
|
|
@@ -485,6 +492,9 @@ interface AbortHarnessOptions {
|
|
|
485
492
|
* Without this hook the byte would be swallowed into the buffered input queue,
|
|
486
493
|
* which is why Ctrl+O historically "did nothing" while the TUI owned stdin. */
|
|
487
494
|
onDetailKey?: () => void;
|
|
495
|
+
/** Invoked when an arrow / PageUp / PageDown key arrives mid-turn — scrolls the
|
|
496
|
+
* open Ctrl+O detail panel. dir -1 = up/back, +1 = down/forward; page = full jump. */
|
|
497
|
+
onScrollKey?: (dir: -1 | 1, page: boolean) => void;
|
|
488
498
|
/** Invoked with printable keyboard input received while the live turn owns stdin. */
|
|
489
499
|
onBufferedInput?: (chunk: string) => void;
|
|
490
500
|
/** True while the input queue is inside a bracketed paste (mid-paste chunks
|
|
@@ -712,6 +722,14 @@ export function createInFlightAbortHarness(opts: AbortHarnessOptions = {}): InFl
|
|
|
712
722
|
opts.onDetailKey?.();
|
|
713
723
|
return;
|
|
714
724
|
}
|
|
725
|
+
// Arrow / PageUp / PageDown — scroll the open Ctrl+O detail panel. Exact-match
|
|
726
|
+
// (whole chunk) like Ctrl+O so embedded sequences in pasted/streamed data don't
|
|
727
|
+
// trigger; bracketed paste already returned above. When the panel is closed the
|
|
728
|
+
// hook is a no-op, so these keys stay inert mid-turn as before.
|
|
729
|
+
if (text === "\u001b[A") { opts.onScrollKey?.(-1, false); return; }
|
|
730
|
+
if (text === "\u001b[B") { opts.onScrollKey?.(1, false); return; }
|
|
731
|
+
if (text === "\u001b[5~") { opts.onScrollKey?.(-1, true); return; }
|
|
732
|
+
if (text === "\u001b[6~") { opts.onScrollKey?.(1, true); return; }
|
|
715
733
|
const escAt = text.indexOf("\u001b");
|
|
716
734
|
const sigintAt = text.indexOf("\u0003");
|
|
717
735
|
const controlAt =
|
|
@@ -1299,6 +1317,19 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1299
1317
|
base.onToolResult?.(tool, success, output);
|
|
1300
1318
|
},
|
|
1301
1319
|
});
|
|
1320
|
+
/** Compose a session-persistence flush into onStep so each completed step is
|
|
1321
|
+
* written as it lands (durability across mid-turn interruption) without
|
|
1322
|
+
* disturbing the original onStep sink. */
|
|
1323
|
+
const withStepPersistence = (
|
|
1324
|
+
base: AgentLoopEvents,
|
|
1325
|
+
persist: () => Promise<void>,
|
|
1326
|
+
): AgentLoopEvents => ({
|
|
1327
|
+
...base,
|
|
1328
|
+
onStep: async (step: number) => {
|
|
1329
|
+
await base.onStep?.(step);
|
|
1330
|
+
await persist();
|
|
1331
|
+
},
|
|
1332
|
+
});
|
|
1302
1333
|
/** The Ctrl+O detail block (shared by the prompt-time keypress handler and the
|
|
1303
1334
|
* mid-turn TUI binding): full last reply + full last tool output. */
|
|
1304
1335
|
const composeDetailLines = (): string[] => {
|
|
@@ -1360,7 +1391,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1360
1391
|
const runTurn = async (
|
|
1361
1392
|
userInput: string,
|
|
1362
1393
|
useTui: boolean,
|
|
1363
|
-
images?: ImageAttachment[]
|
|
1394
|
+
images?: ImageAttachment[],
|
|
1395
|
+
// What to show as the user card in the live frame: undefined → the prompt
|
|
1396
|
+
// itself (normal turns); null → suppress it entirely (skill runs, where the
|
|
1397
|
+
// injected SKILL.md is NOT user-authored and the [skill] card already names it,
|
|
1398
|
+
// gjc-style); a string → show that compact label instead of the raw input.
|
|
1399
|
+
opts?: { userCard?: string | null },
|
|
1364
1400
|
): Promise<{ done: boolean; steps: number; reply: string; rendered: boolean; usage: string }> => {
|
|
1365
1401
|
const turnConfig = await readGlobalConfig();
|
|
1366
1402
|
const activeModel = sessionModel || turnConfig.defaultModel;
|
|
@@ -1377,6 +1413,17 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1377
1413
|
// AFTER compaction (which mutates history) and consumed by the post-turn
|
|
1378
1414
|
// persistence block below.
|
|
1379
1415
|
let beforeLen = history.length;
|
|
1416
|
+
// Incremental session persistence (durability across mid-turn interruption):
|
|
1417
|
+
// persistTurnTail() flushes history messages added since the last flush — called
|
|
1418
|
+
// right after the user prompt, on every onStep boundary, and once post-turn — so
|
|
1419
|
+
// Ctrl+C / crash / ESC can't lose the prompt + finished steps before /resume.
|
|
1420
|
+
let persistedLen = beforeLen;
|
|
1421
|
+
const persistTurnTail = async (): Promise<void> => {
|
|
1422
|
+
if (!sessionId || history.length <= persistedLen) return;
|
|
1423
|
+
const tail = history.slice(persistedLen);
|
|
1424
|
+
persistedLen = history.length;
|
|
1425
|
+
try { await appendMessages(sessionId, tail, cwd); } catch { /* best-effort */ }
|
|
1426
|
+
};
|
|
1380
1427
|
let result;
|
|
1381
1428
|
try {
|
|
1382
1429
|
// Paint the live frame + spinner the INSTANT the turn is accepted, BEFORE the
|
|
@@ -1401,17 +1448,24 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1401
1448
|
tui?.events().onNotice?.(`(compacted ${compRes.removed} older message${compRes.removed === 1 ? "" : "s"})`);
|
|
1402
1449
|
}
|
|
1403
1450
|
beforeLen = history.length;
|
|
1451
|
+
persistedLen = beforeLen; // re-baseline after compaction mutated history
|
|
1404
1452
|
if (images?.length && catalogMetadata(activeModel)?.images === false) {
|
|
1405
1453
|
const warn = `! ${activeModel} does not advertise image input — sending the attachment anyway.`;
|
|
1406
1454
|
if (tui) tui.events().onNotice?.(warn);
|
|
1407
1455
|
else console.log(warn);
|
|
1408
1456
|
}
|
|
1409
1457
|
history.push(images?.length ? { role: "user", content: userInput, images } : { role: "user", content: userInput });
|
|
1458
|
+
// Persist the user prompt immediately so an interrupted turn keeps it on disk.
|
|
1459
|
+
await persistTurnTail(); // persist the user prompt immediately
|
|
1410
1460
|
// Keep the submitted query in scrollback: the prompt that STARTS a turn shows
|
|
1411
1461
|
// only as the transient HUD turn-title otherwise, which vanishes when the live
|
|
1412
1462
|
// frame clears at turn-end — so the conversation transcript lost every user
|
|
1413
1463
|
// prompt. Flush a `user` card (same surface as a mid-turn steer) so it persists.
|
|
1414
|
-
|
|
1464
|
+
// Flush a `user` card (same surface as a mid-turn steer) so the prompt persists
|
|
1465
|
+
// in scrollback. opts.userCard overrides it: null suppresses the card entirely
|
|
1466
|
+
// (skill runs), a string shows a compact label instead of the raw input.
|
|
1467
|
+
const cardText = opts?.userCard === undefined ? userInput : opts.userCard;
|
|
1468
|
+
if (tui && cardText && cardText.trim()) tui.flushUserCard(cardText);
|
|
1415
1469
|
tui?.setContextUsage(historyTokens(history), contextTokens);
|
|
1416
1470
|
|
|
1417
1471
|
// Per-turn steering inbox (gjc parity): additional queries typed mid-turn land
|
|
@@ -1440,6 +1494,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1440
1494
|
if (lines.length === 0) return;
|
|
1441
1495
|
tui.showDetail(lines);
|
|
1442
1496
|
},
|
|
1497
|
+
onScrollKey: (dir, page) => tui?.scrollDetail(dir, page),
|
|
1443
1498
|
onBufferedInput: chunk => {
|
|
1444
1499
|
if (!tui) return;
|
|
1445
1500
|
// gjc-style mid-turn steering: a typed Enter (outside a bracketed paste)
|
|
@@ -1535,7 +1590,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1535
1590
|
maxTokens: sessionThinking ? thinkingMaxTokens(sessionThinking) : undefined,
|
|
1536
1591
|
signal: ac.signal,
|
|
1537
1592
|
steer: drainSteer,
|
|
1538
|
-
events: wrapEvents({ ...withToolDetailCapture(tui ? tui.events() : streamEvents), onBeforeDone }, opik),
|
|
1593
|
+
events: wrapEvents(withStepPersistence({ ...withToolDetailCapture(tui ? tui.events() : streamEvents), onBeforeDone }, persistTurnTail), opik),
|
|
1539
1594
|
});
|
|
1540
1595
|
if (result.done && looksLikeSkillEcho(result.doneReason ?? "", resolvedSkills)) {
|
|
1541
1596
|
history.push({
|
|
@@ -1590,13 +1645,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1590
1645
|
|| (result.done
|
|
1591
1646
|
? `(done in ${result.steps} step${result.steps === 1 ? "" : "s"} — the model returned no summary)`
|
|
1592
1647
|
: `(reached the ${result.steps}-step limit without signaling done)`);
|
|
1593
|
-
//
|
|
1594
|
-
//
|
|
1648
|
+
// Persist any messages this turn produced that onStep hasn't flushed yet (the
|
|
1649
|
+
// final step's tool-call/result + any retry-loop messages), then the reply.
|
|
1650
|
+
// Incremental onStep flushes already wrote the prompt and completed steps, so
|
|
1651
|
+
// this only covers the tail — net content is the full turn either way.
|
|
1595
1652
|
try {
|
|
1596
|
-
|
|
1597
|
-
// One batched fs append for the whole turn (was: one awaited append per message).
|
|
1598
|
-
await appendMessages(sessionId, history.slice(beforeLen), cwd);
|
|
1599
|
-
}
|
|
1653
|
+
await persistTurnTail();
|
|
1600
1654
|
history.push({ role: "assistant", content: reply });
|
|
1601
1655
|
if (sessionId) await appendMessage(sessionId, { role: "assistant", content: reply }, cwd);
|
|
1602
1656
|
if (tui) tui.finish(reply);
|
|
@@ -1716,7 +1770,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1716
1770
|
console.log(`▶ Running skill: ${inv.skill.name}${inv.intent ? ` — ${inv.intent}` : ""}`);
|
|
1717
1771
|
}
|
|
1718
1772
|
const task = buildSkillTask(inv.skill, inv.intent, inv.invokedAs);
|
|
1719
|
-
const { reply, rendered, usage } = await runTurn(task, useOneShotTui);
|
|
1773
|
+
const { reply, rendered, usage } = await runTurn(task, useOneShotTui, undefined, { userCard: null });
|
|
1720
1774
|
if (!rendered) console.log(stripMarkdown(renderMarkdownTables(reply)) + usage);
|
|
1721
1775
|
else if (usage) console.log(usage.trim());
|
|
1722
1776
|
};
|
|
@@ -1824,7 +1878,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1824
1878
|
// starts — skill name, resolved SKILL.md path, and the prompt size.
|
|
1825
1879
|
{
|
|
1826
1880
|
const card = formatForgeBox(
|
|
1827
|
-
{ title: "[skill]", lines: skillInvocationCard(skill) },
|
|
1881
|
+
{ title: "[skill]", lines: skillInvocationCard(skill, intent) },
|
|
1828
1882
|
{ width: Math.min(100, Math.max(40, (process.stdout.columns ?? 80) - 2)), unicode: supportsUnicode(), paint: accentPaint(uiTheme), paintShadow: accentShadowPaint(uiTheme), color: uiTheme.color },
|
|
1829
1883
|
);
|
|
1830
1884
|
logLines(card);
|
|
@@ -1905,7 +1959,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1905
1959
|
// final reply is the skill's result.
|
|
1906
1960
|
if (!useTui) console.log(`▶ Running skill: ${skill.name}${intent ? ` — ${intent}` : ""}`);
|
|
1907
1961
|
const task = buildSkillTask(skill, intent, invokedAs);
|
|
1908
|
-
const { reply, rendered, usage } = await runTurn(task, useTui);
|
|
1962
|
+
const { reply, rendered, usage } = await runTurn(task, useTui, undefined, { userCard: null });
|
|
1909
1963
|
if (!rendered) console.log(`jeo> ${stripMarkdown(renderMarkdownTables(reply))}${usage}`);
|
|
1910
1964
|
else if (usage) console.log(usage.trim());
|
|
1911
1965
|
}
|
|
@@ -2061,6 +2115,18 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2061
2115
|
completer: (line: string) => readlineCompleter(line, completionContext()),
|
|
2062
2116
|
});
|
|
2063
2117
|
activeRl = rl; // wire the input filter's empty-line backspace guard to the live buffer
|
|
2118
|
+
// Cross-launch input history: hydrate readline's ↑/↓ ring with prompts from
|
|
2119
|
+
// previous sessions in this workspace, so a fresh launch can recall "이전에
|
|
2120
|
+
// 사용한 쿼리" — not just lines typed in the current run. readline keeps history
|
|
2121
|
+
// newest-first, which is exactly the order loadInputHistory returns.
|
|
2122
|
+
if (process.stdin.isTTY) {
|
|
2123
|
+
const rliHist = rl as unknown as { history?: string[] };
|
|
2124
|
+
if (Array.isArray(rliHist.history)) {
|
|
2125
|
+
for (const entry of loadInputHistory(cwd)) {
|
|
2126
|
+
if (!rliHist.history.includes(entry)) rliHist.history.push(entry);
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2064
2130
|
const promptStdin = process.stdin as typeof process.stdin & { isRaw?: boolean; setRawMode?(raw: boolean): void };
|
|
2065
2131
|
const promptWasRaw = !!promptStdin.isRaw;
|
|
2066
2132
|
let promptRawChanged = false;
|
|
@@ -2274,6 +2340,13 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2274
2340
|
}
|
|
2275
2341
|
};
|
|
2276
2342
|
let previewPending = false;
|
|
2343
|
+
// Idle-prompt Ctrl+O detail panel: a REVERSIBLE, scrollable overlay drawn inside
|
|
2344
|
+
// the footer reservation. Toggle open/closed with Ctrl+O; ↑↓/PgUp/PgDn scroll so
|
|
2345
|
+
// long/CJK content is fully reachable (no "… N more" clip). null = closed.
|
|
2346
|
+
let promptHistoryLines: string[] | null = null;
|
|
2347
|
+
let promptHistoryScroll = 0;
|
|
2348
|
+
let promptHistoryMaxScroll = 0;
|
|
2349
|
+
let promptHistoryPage = 1;
|
|
2277
2350
|
|
|
2278
2351
|
// Inline boxed-footer rendering with a FIXED reservation (the "@-mention typing
|
|
2279
2352
|
// pushes the box down" fix). The footer reserves its full `footerRows` height
|
|
@@ -2425,6 +2498,44 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2425
2498
|
const preview = (slash.length ? slash : args).map(l => chalk.gray(truncateAnsi(l, cols)));
|
|
2426
2499
|
return [statusBarLine(cols), "", ...input, ...preview].slice(0, footerRows);
|
|
2427
2500
|
};
|
|
2501
|
+
// Render the reversible Ctrl+O detail panel into the footer reservation: a status
|
|
2502
|
+
// bar, a title (with scroll hint when needed), then a windowed slice of the detail
|
|
2503
|
+
// with ↑/↓ counters. Recomputes the scroll bound/page size each paint so scroll
|
|
2504
|
+
// keys can clamp correctly. Mirrors the mid-turn LaunchTui panel.
|
|
2505
|
+
const historyPreviewLines = (detail: string[]): string[] => {
|
|
2506
|
+
const cols = Math.max(24, (process.stdout.columns ?? 80) - 1);
|
|
2507
|
+
const physical = detail.flatMap(line => line.split("\n")).map(line => truncateAnsi(line, cols));
|
|
2508
|
+
const bodyLimit = Math.max(1, footerRows - 2); // status bar + title rows
|
|
2509
|
+
const scrollable = physical.length > bodyLimit;
|
|
2510
|
+
const cap = scrollable ? Math.max(1, bodyLimit - 2) : bodyLimit;
|
|
2511
|
+
promptHistoryPage = cap;
|
|
2512
|
+
promptHistoryMaxScroll = Math.max(0, physical.length - cap);
|
|
2513
|
+
if (promptHistoryScroll > promptHistoryMaxScroll) promptHistoryScroll = promptHistoryMaxScroll;
|
|
2514
|
+
const hint = scrollable ? "· ↑↓/PgUp/PgDn scroll · Ctrl+O closes" : "· Ctrl+O closes";
|
|
2515
|
+
const title = `${uiAccent("history")} ${chalk.dim(hint)}`;
|
|
2516
|
+
let body: string[];
|
|
2517
|
+
if (!scrollable) {
|
|
2518
|
+
body = physical;
|
|
2519
|
+
} else {
|
|
2520
|
+
const start = promptHistoryScroll;
|
|
2521
|
+
const above = start;
|
|
2522
|
+
const reserveTop = above > 0 ? 1 : 0;
|
|
2523
|
+
let innerLimit = Math.max(1, bodyLimit - reserveTop - 1);
|
|
2524
|
+
let win = physical.slice(start, start + innerLimit);
|
|
2525
|
+
let below = physical.length - (start + win.length);
|
|
2526
|
+
if (below === 0) {
|
|
2527
|
+
innerLimit = Math.max(1, bodyLimit - reserveTop);
|
|
2528
|
+
win = physical.slice(start, start + innerLimit);
|
|
2529
|
+
below = physical.length - (start + win.length);
|
|
2530
|
+
}
|
|
2531
|
+
body = [];
|
|
2532
|
+
if (above > 0) body.push(chalk.dim(`↑ ${above} more above`));
|
|
2533
|
+
body.push(...win);
|
|
2534
|
+
if (below > 0) body.push(chalk.dim(`↓ ${below} more below`));
|
|
2535
|
+
}
|
|
2536
|
+
footerCursor = { row: Math.min(1, footerRows - 1), col: 1 };
|
|
2537
|
+
return [statusBarLine(cols), title, ...body].slice(0, footerRows);
|
|
2538
|
+
};
|
|
2428
2539
|
const drawFooter = (lines: string[]) => {
|
|
2429
2540
|
if (!previewArmed || footerRendered === 0) return;
|
|
2430
2541
|
// ALWAYS paint exactly footerRendered rows so the reservation is fully covered
|
|
@@ -2871,24 +2982,41 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2871
2982
|
return;
|
|
2872
2983
|
}
|
|
2873
2984
|
if (!previewArmed || pickerActive) return;
|
|
2874
|
-
// Ctrl+O:
|
|
2875
|
-
//
|
|
2876
|
-
//
|
|
2877
|
-
// (Cmd+O is intercepted by
|
|
2985
|
+
// Ctrl+O: toggle a reversible, scrollable detail panel inside the footer
|
|
2986
|
+
// (expand on the first press, FOLD on the next). ↑↓/PgUp/PgDn scroll it while
|
|
2987
|
+
// open — long/CJK content is fully reachable, nothing is clipped. The live-turn
|
|
2988
|
+
// path uses LaunchTui.showDetail(). (Cmd+O is intercepted by the terminal.)
|
|
2878
2989
|
if (key?.ctrl && key.name === "o") {
|
|
2990
|
+
if (promptHistoryLines) {
|
|
2991
|
+
promptHistoryLines = null; // fold
|
|
2992
|
+
drawFooter(previewLines(typedLine, navIdx));
|
|
2993
|
+
return;
|
|
2994
|
+
}
|
|
2879
2995
|
const detail = composeDetailLines();
|
|
2880
2996
|
if (detail.length === 0) return;
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
// the same path the resize handler/main loop use, so the input box and the
|
|
2885
|
-
// typed draft restore without corruption.
|
|
2886
|
-
disarmPreview();
|
|
2887
|
-
logLines(detail);
|
|
2888
|
-
armPreview();
|
|
2889
|
-
drawFooter(previewLines(typedLine, navIdx));
|
|
2997
|
+
promptHistoryLines = detail;
|
|
2998
|
+
promptHistoryScroll = 0;
|
|
2999
|
+
drawFooter(historyPreviewLines(detail));
|
|
2890
3000
|
return;
|
|
2891
3001
|
}
|
|
3002
|
+
// While the detail panel is open, arrows / PgUp / PgDn scroll it instead of
|
|
3003
|
+
// navigating slash/history; every other key (below) closes it.
|
|
3004
|
+
if (promptHistoryLines && key && (key.name === "up" || key.name === "down" || key.name === "pageup" || key.name === "pagedown")) {
|
|
3005
|
+
const page = key.name === "pageup" || key.name === "pagedown";
|
|
3006
|
+
const dir = key.name === "up" || key.name === "pageup" ? -1 : 1;
|
|
3007
|
+
const step = page ? Math.max(1, promptHistoryPage - 1) : 1;
|
|
3008
|
+
const next = Math.min(promptHistoryMaxScroll, Math.max(0, promptHistoryScroll + dir * step));
|
|
3009
|
+
if (next !== promptHistoryScroll) {
|
|
3010
|
+
promptHistoryScroll = next;
|
|
3011
|
+
drawFooter(historyPreviewLines(promptHistoryLines));
|
|
3012
|
+
}
|
|
3013
|
+
return;
|
|
3014
|
+
}
|
|
3015
|
+
if (promptHistoryLines) {
|
|
3016
|
+
// Any other key dismisses the panel, then falls through to normal handling.
|
|
3017
|
+
promptHistoryLines = null;
|
|
3018
|
+
drawFooter(previewLines(typedLine, navIdx));
|
|
3019
|
+
}
|
|
2892
3020
|
// Ctrl+V: attach a clipboard IMAGE to the next message. Terminal text paste
|
|
2893
3021
|
// never arrives as a ctrl+v keypress (it streams as plain stdin data), so this
|
|
2894
3022
|
// binding is image-only; when the clipboard holds no image it's a silent no-op.
|
|
@@ -2931,7 +3059,13 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2931
3059
|
if (!previewArmed) return;
|
|
2932
3060
|
try {
|
|
2933
3061
|
if (key && (key.name === "return" || key.name === "enter")) {
|
|
2934
|
-
drawFooter([])
|
|
3062
|
+
// Redraw the REAL box (not drawFooter([]), which erases it): this runs in a
|
|
3063
|
+
// setImmediate and can fire AFTER the loop already re-armed + repainted the box
|
|
3064
|
+
// for the next prompt (slash command / turn). An empty draw there wiped the fresh
|
|
3065
|
+
// box and parked the cursor at the reservation top — the "input box vanishes and
|
|
3066
|
+
// the caret leaves the prompt after a command" bug. Drawing previewLines keeps the
|
|
3067
|
+
// box present and the caret in it whether this fires before submit or after re-arm.
|
|
3068
|
+
drawFooter(previewLines(typedLine, navIdx));
|
|
2935
3069
|
return;
|
|
2936
3070
|
}
|
|
2937
3071
|
// Arrow up/down: move the highlight over the slash keyword preview list.
|
|
@@ -2991,7 +3125,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2991
3125
|
try {
|
|
2992
3126
|
disarmPreview();
|
|
2993
3127
|
armPreview();
|
|
2994
|
-
drawFooter(previewLines(typedLine, navIdx));
|
|
3128
|
+
drawFooter(promptHistoryLines ? historyPreviewLines(promptHistoryLines) : previewLines(typedLine, navIdx));
|
|
2995
3129
|
} catch { /* ignore resize render races */ }
|
|
2996
3130
|
};
|
|
2997
3131
|
process.stdout.on("resize", idleResizeHandler);
|
|
@@ -3030,6 +3164,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
3030
3164
|
// window where the box is gone and keystrokes echo nowhere (the "no response
|
|
3031
3165
|
// after the result" gap). readline's own echo stays gated while armed.
|
|
3032
3166
|
armPreview();
|
|
3167
|
+
promptHistoryLines = null; // each fresh prompt starts with the Ctrl+O panel closed
|
|
3033
3168
|
if (prefilledLine) {
|
|
3034
3169
|
rli.line = prefilledLine;
|
|
3035
3170
|
rli.cursor = prefilledLine.length;
|
|
@@ -3047,6 +3182,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
3047
3182
|
if (rawText.includes("\u0003")) forceExitFromCtrlC();
|
|
3048
3183
|
const raw = rawText.trim();
|
|
3049
3184
|
disarmPreview();
|
|
3185
|
+
// Persist the submitted line so ↑ recalls it on a future launch (best-effort).
|
|
3186
|
+
if (raw && process.stdin.isTTY) appendInputHistory(cwd, raw);
|
|
3050
3187
|
// Pasted batch command: echo what is about to run (with the remaining queue
|
|
3051
3188
|
// depth) so a multi-line paste reads as a visible, ordered script.
|
|
3052
3189
|
if (promptServedFromPaste && raw) {
|
package/src/commands/update.ts
CHANGED
|
@@ -38,6 +38,9 @@ export interface UpdateDeps {
|
|
|
38
38
|
install: (version?: string) => Promise<{ success: boolean; stdout?: string; stderr?: string }>;
|
|
39
39
|
/** Display release notes after a successful self-update (best-effort, no-op in tests). */
|
|
40
40
|
showWhatsNew?: () => void;
|
|
41
|
+
/** Version of the `jeo` actually on PATH, read after install to catch a silent no-op
|
|
42
|
+
* (installed but PATH still points at a different/older binary). Optional in tests. */
|
|
43
|
+
activeVersion?: () => string | null;
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
export const defaultDeps: UpdateDeps = {
|
|
@@ -59,15 +62,41 @@ export const defaultDeps: UpdateDeps = {
|
|
|
59
62
|
return pkg.version;
|
|
60
63
|
},
|
|
61
64
|
install: async (version?: string) => {
|
|
62
|
-
// Self-update the global install
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
+
// Self-update the global install via the SAME toolchain it was installed with. jeo
|
|
66
|
+
// ships as a `#!/usr/bin/env bun` script so bun is the common case, but npm-installed
|
|
67
|
+
// globals exist too — try bun first, fall back to npm (and tolerate either missing).
|
|
68
|
+
// `@<version>` (the resolved latest) forces the newest publish past a stale global cache.
|
|
65
69
|
const target = `jeo-code@${version ?? "latest"}`;
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
const managers: string[][] = [
|
|
71
|
+
["bun", "install", "-g", target],
|
|
72
|
+
["npm", "install", "-g", target],
|
|
73
|
+
];
|
|
74
|
+
let lastErr = "";
|
|
75
|
+
for (const cmd of managers) {
|
|
76
|
+
try {
|
|
77
|
+
const proc = Bun.spawnSync(cmd, { stdout: "inherit", stderr: "inherit" });
|
|
78
|
+
if (proc.success) return { success: true };
|
|
79
|
+
lastErr = `${cmd[0]} exited with code ${proc.exitCode}`;
|
|
80
|
+
} catch (err: any) {
|
|
81
|
+
lastErr = `${cmd[0]} unavailable: ${err?.message ?? String(err)}`;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { success: false, stderr: lastErr };
|
|
85
|
+
}
|
|
86
|
+
,
|
|
87
|
+
activeVersion: () => {
|
|
88
|
+
// Read the version of the `jeo` actually on PATH (may differ from this process's bundled
|
|
89
|
+
// version after a self-update, e.g. when bun installed to ~/.bun/bin but PATH prefers an
|
|
90
|
+
// npm global). Used to detect a silent "installed but PATH unchanged" no-op.
|
|
91
|
+
try {
|
|
92
|
+
const proc = Bun.spawnSync(["jeo", "--version"], { stdout: "pipe", stderr: "pipe" });
|
|
93
|
+
if (!proc.success) return null;
|
|
94
|
+
const out = new TextDecoder().decode(proc.stdout);
|
|
95
|
+
const m = out.match(/(\d+\.\d+\.\d+(?:-[\w.]+)?)/);
|
|
96
|
+
return m ? m[1] : null;
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
71
100
|
}
|
|
72
101
|
,
|
|
73
102
|
showWhatsNew: () => {
|
|
@@ -193,6 +222,16 @@ export async function runUpdateCommandWith(args: string[], deps: UpdateDeps): Pr
|
|
|
193
222
|
}));
|
|
194
223
|
} else {
|
|
195
224
|
console.log(`Successfully installed jeo-code@${latest}`);
|
|
225
|
+
// Verify the `jeo` on PATH actually picked up the new install — a bun-vs-npm or
|
|
226
|
+
// PATH mismatch can leave an older binary in front, which looks like "update did
|
|
227
|
+
// nothing". Surface that loudly with the manual fix instead of failing silently.
|
|
228
|
+
const active = deps.activeVersion?.();
|
|
229
|
+
if (active && latest && compareVersions(active, latest) < 0) {
|
|
230
|
+
console.warn(`Warning: installed ${latest}, but the active 'jeo' on PATH still reports ${active}.`);
|
|
231
|
+
console.warn(`Your PATH points at a different install. Fix it with one of:`);
|
|
232
|
+
console.warn(` npm install -g jeo-code@${latest}`);
|
|
233
|
+
console.warn(` bun install -g jeo-code@${latest}`);
|
|
234
|
+
}
|
|
196
235
|
deps.showWhatsNew?.();
|
|
197
236
|
}
|
|
198
237
|
} else {
|
package/src/skills/catalog.ts
CHANGED
|
@@ -450,13 +450,20 @@ export async function loadSkills(cwd: string = process.cwd()): Promise<SkillDoc[
|
|
|
450
450
|
/** gjc-style skill-invocation card body (the `[skill]` block shown in the TUI
|
|
451
451
|
* when `$name`//skill runs): name, resolved SKILL.md path (or the bundled
|
|
452
452
|
* module path), and the prompt size actually injected. Pure — testable. */
|
|
453
|
-
export function skillInvocationCard(skill: SkillDoc): string[] {
|
|
453
|
+
export function skillInvocationCard(skill: SkillDoc, intent?: string): string[] {
|
|
454
454
|
const promptLines = (skill.raw ?? skill.details ?? "").split("\n").filter(l => l.trim().length > 0).length;
|
|
455
455
|
// jeo-ref tree-connector detail: the skill name leads, resolved metadata hangs
|
|
456
|
-
// off ├─/└─ connectors so the card scans like the reference's Skill panel.
|
|
456
|
+
// off ├─/└─ connectors so the card scans like the reference's Skill panel. The
|
|
457
|
+
// intent (when given) is shown here so it isn't lost — the injected SKILL.md is
|
|
458
|
+
// NOT echoed as a user box (gjc-style: compact card, not the raw doc).
|
|
459
|
+
const trimmedIntent = intent?.trim();
|
|
460
|
+
const intentLine = trimmedIntent
|
|
461
|
+
? [`├─ intent: ${trimmedIntent.length > 88 ? `${trimmedIntent.slice(0, 87)}…` : trimmedIntent}`]
|
|
462
|
+
: [];
|
|
457
463
|
return [
|
|
458
464
|
`Skill: ${skill.name}`,
|
|
459
465
|
`├─ path: ${skill.sourcePath ?? `(bundled) src/prompts/skills/${skill.name}/SKILL.md`}`,
|
|
466
|
+
...intentLine,
|
|
460
467
|
`└─ prompt: ${promptLines} lines`,
|
|
461
468
|
];
|
|
462
469
|
}
|
package/src/tui/app.ts
CHANGED
|
@@ -225,6 +225,12 @@ export class LaunchTui {
|
|
|
225
225
|
// block above the heartbeat; pressing Ctrl+O again clears it and restores the
|
|
226
226
|
// normal activity view. Kept as data, not scrollback text, so it can actually close.
|
|
227
227
|
private historyLines: string[] | null = null;
|
|
228
|
+
// Ctrl+O detail panel scroll: a window offset so long/CJK content is fully
|
|
229
|
+
// reachable (↑↓/PgUp/PgDn) instead of clipped at "… N more". Bound + page size
|
|
230
|
+
// are recomputed each render from the visible body height.
|
|
231
|
+
private historyScroll = 0;
|
|
232
|
+
private historyMaxScroll = 0;
|
|
233
|
+
private historyPageSize = 1;
|
|
228
234
|
// Kind of the last ledger entry — drives the gjc-reference vertical rhythm: a
|
|
229
235
|
// blank line separates DIFFERENT ledger groups (card ↔ ✓-tool lines ↔ reasoning
|
|
230
236
|
// ↔ notices), while same-kind lines (consecutive ✓ reads) stay adjacent.
|
|
@@ -543,9 +549,10 @@ export class LaunchTui {
|
|
|
543
549
|
};
|
|
544
550
|
}
|
|
545
551
|
|
|
546
|
-
/** Ctrl+O history/detail toggle, mid-turn: first press opens a live
|
|
547
|
-
* the full last reply / tool output, second press closes it and
|
|
548
|
-
* normal activity frame.
|
|
552
|
+
/** Ctrl+O history/detail toggle, mid-turn: first press opens a SCROLLABLE live
|
|
553
|
+
* panel with the full last reply / tool output, second press closes it and
|
|
554
|
+
* returns to the normal activity frame. Reversible; the full content is reachable
|
|
555
|
+
* via scrollDetail (↑↓/PgUp/PgDn) so nothing is lost to "… N more" clipping. */
|
|
549
556
|
showDetail(lines: string[]): void {
|
|
550
557
|
if (this.finished) return;
|
|
551
558
|
if (this.historyLines) {
|
|
@@ -555,6 +562,19 @@ export class LaunchTui {
|
|
|
555
562
|
}
|
|
556
563
|
if (lines.length === 0) return;
|
|
557
564
|
this.historyLines = lines;
|
|
565
|
+
this.historyScroll = 0;
|
|
566
|
+
this.draw();
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Scroll the open Ctrl+O detail panel. dir -1 = up/back, +1 = down/forward;
|
|
570
|
+
* `page` jumps a full visible body height. No-op when the panel is closed, so it
|
|
571
|
+
* is safe to wire unconditionally to arrow/PgUp/PgDn keys. */
|
|
572
|
+
scrollDetail(dir: -1 | 1, page = false): void {
|
|
573
|
+
if (this.finished || !this.historyLines) return;
|
|
574
|
+
const step = page ? Math.max(1, this.historyPageSize - 1) : 1;
|
|
575
|
+
const next = Math.min(this.historyMaxScroll, Math.max(0, this.historyScroll + dir * step));
|
|
576
|
+
if (next === this.historyScroll) return;
|
|
577
|
+
this.historyScroll = next;
|
|
558
578
|
this.draw();
|
|
559
579
|
}
|
|
560
580
|
|
|
@@ -746,6 +766,15 @@ export class LaunchTui {
|
|
|
746
766
|
// (e.g. "rate limited (HTTP 429) — auto-retry #2 in 4s") instead of an opaque
|
|
747
767
|
// ever-growing "calling model (18.4s)…".
|
|
748
768
|
if (this.retryNotice) return `${this.retryNotice} (${elapsed}s)`;
|
|
769
|
+
// Reasoning is streaming → the live thought block already shows it; label the row.
|
|
770
|
+
if (this.streamingThought.trim() || this.streamingReasoning.trim()) {
|
|
771
|
+
return `reasoning (${this.footer.model}) (${elapsed}s)…`;
|
|
772
|
+
}
|
|
773
|
+
// No tokens after a few seconds: the model is almost certainly reasoning
|
|
774
|
+
// server-side (e.g. OpenAI hidden reasoning), NOT hung — say so instead of a
|
|
775
|
+
// frozen "calling model …" so a long silent wait still reads as progress.
|
|
776
|
+
const waited = this.currentStepStartedAt ? (Date.now() - this.currentStepStartedAt) / 1000 : 0;
|
|
777
|
+
if (waited >= 8) return `thinking (${this.footer.model}) — reasoning, no token stream yet (${elapsed}s)…`;
|
|
749
778
|
return `calling model (${this.footer.model}) (${elapsed}s)…`;
|
|
750
779
|
}
|
|
751
780
|
if (running) {
|
|
@@ -1085,20 +1114,42 @@ export class LaunchTui {
|
|
|
1085
1114
|
const inner = Math.max(10, boxWidth - 2);
|
|
1086
1115
|
const accent = this.theme.color ? accentPaint(this.theme) : (s: string) => s;
|
|
1087
1116
|
const dim = this.theme.color ? chalk.dim : (s: string) => s;
|
|
1088
|
-
const title = `${accent("history")} ${dim("· Ctrl+O closes")}`;
|
|
1089
1117
|
const wrapped = this.historyLines.flatMap(line => {
|
|
1090
1118
|
const physical = line.split("\n");
|
|
1091
1119
|
return physical.flatMap(part => (visibleWidth(part) <= inner ? [part] : wrapTextWithAnsi(part, inner)));
|
|
1092
1120
|
});
|
|
1121
|
+
const bodyLimit = Math.max(1, maxRows - 4); // box borders (2) + title + divider
|
|
1122
|
+
const scrollable = wrapped.length > bodyLimit;
|
|
1123
|
+
// Window capacity at the bottom (worst case: both ↑ and ↓ indicators take a row),
|
|
1124
|
+
// so the last line is always reachable by scrolling.
|
|
1125
|
+
const cap = scrollable ? Math.max(1, bodyLimit - 2) : bodyLimit;
|
|
1126
|
+
this.historyPageSize = cap;
|
|
1127
|
+
this.historyMaxScroll = Math.max(0, wrapped.length - cap);
|
|
1128
|
+
if (this.historyScroll > this.historyMaxScroll) this.historyScroll = this.historyMaxScroll;
|
|
1129
|
+
const hint = scrollable ? "· ↑↓/PgUp/PgDn scroll · Ctrl+O closes" : "· Ctrl+O closes";
|
|
1130
|
+
const title = `${accent("history")} ${dim(hint)}`;
|
|
1093
1131
|
const header = [title, "DIVIDER"];
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
const keep = Math.max(0, bodyLimit - 1);
|
|
1098
|
-
body = wrapped.slice(0, keep);
|
|
1099
|
-
body.push(dim(`… ${wrapped.length - keep} more line(s)`));
|
|
1132
|
+
let body: string[];
|
|
1133
|
+
if (!scrollable) {
|
|
1134
|
+
body = wrapped;
|
|
1100
1135
|
} else {
|
|
1101
|
-
|
|
1136
|
+
// Window the content and show ↑/↓ counters instead of dropping the tail at
|
|
1137
|
+
// "… N more": every line is reachable via scrollDetail (arrows / PgUp-PgDn).
|
|
1138
|
+
const start = this.historyScroll;
|
|
1139
|
+
const above = start;
|
|
1140
|
+
const reserveTop = above > 0 ? 1 : 0;
|
|
1141
|
+
let innerLimit = Math.max(1, bodyLimit - reserveTop - 1);
|
|
1142
|
+
let win = wrapped.slice(start, start + innerLimit);
|
|
1143
|
+
let below = wrapped.length - (start + win.length);
|
|
1144
|
+
if (below === 0) {
|
|
1145
|
+
innerLimit = Math.max(1, bodyLimit - reserveTop);
|
|
1146
|
+
win = wrapped.slice(start, start + innerLimit);
|
|
1147
|
+
below = wrapped.length - (start + win.length);
|
|
1148
|
+
}
|
|
1149
|
+
body = [];
|
|
1150
|
+
if (above > 0) body.push(dim(`↑ ${above} more above`));
|
|
1151
|
+
body.push(...win);
|
|
1152
|
+
if (below > 0) body.push(dim(`↓ ${below} more below`));
|
|
1102
1153
|
}
|
|
1103
1154
|
return boxBlock([...header, ...body], boxWidth, {
|
|
1104
1155
|
glyphs: this.unicode ? BOX_UNICODE : BOX_ASCII,
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import chalk from "chalk";
|
|
15
15
|
import type { Message } from "../../ai/types";
|
|
16
16
|
import { summarizeForgeInvocation } from "./forge";
|
|
17
|
+
import { tryExtractJsonObject } from "../../agent/json";
|
|
17
18
|
|
|
18
19
|
export interface TranscriptOptions {
|
|
19
20
|
color?: boolean;
|
|
@@ -106,15 +107,20 @@ export function formatTranscript(messages: readonly Message[], opts: TranscriptO
|
|
|
106
107
|
lines.push(...clipBody(m.content, bodyCap));
|
|
107
108
|
continue;
|
|
108
109
|
}
|
|
109
|
-
// assistant:
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
110
|
+
// assistant: a JSON tool call (compact ledger lines) or a prose/done reply.
|
|
111
|
+
// A tool-call message IS a JSON object (optionally inside a ```json fence) — so
|
|
112
|
+
// only parse when the content actually begins with `{` after stripping a leading
|
|
113
|
+
// fence. This renders fenced/decorated tool calls as cards (the "/resume shows
|
|
114
|
+
// raw JSON and breaks the TUI" bug — naive JSON.parse failed on any fence and
|
|
115
|
+
// dumped the block) while prose that merely CONTAINS tool-like JSON stays prose.
|
|
116
|
+
const stripped = m.content.trim().replace(/^```(?:json)?[ \t]*\r?\n?/i, "").trimStart();
|
|
117
|
+
const looksLikeCall = stripped.startsWith("{");
|
|
118
|
+
const parsed = looksLikeCall
|
|
119
|
+
? tryExtractJsonObject<{ tool?: unknown; tools?: unknown; arguments?: unknown }>(
|
|
120
|
+
m.content,
|
|
121
|
+
{ preferKeys: ["tool", "tools"] },
|
|
122
|
+
)
|
|
123
|
+
: null;
|
|
118
124
|
const calls: { tool: string; arguments?: unknown }[] =
|
|
119
125
|
parsed && typeof parsed.tool === "string"
|
|
120
126
|
? [{ tool: parsed.tool, arguments: parsed.arguments }]
|
|
@@ -138,10 +144,14 @@ export function formatTranscript(messages: readonly Message[], opts: TranscriptO
|
|
|
138
144
|
});
|
|
139
145
|
continue;
|
|
140
146
|
}
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
147
|
+
// No tool calls → a lone `{tool:"done", reason}` (show its reason), a JSON object
|
|
148
|
+
// that isn't a renderable call (skip — never dump raw JSON), or genuine prose.
|
|
149
|
+
const reason =
|
|
150
|
+
parsed && parsed.tool === "done"
|
|
151
|
+
? String((parsed.arguments as { reason?: unknown } | undefined)?.reason ?? "")
|
|
152
|
+
: looksLikeCall
|
|
153
|
+
? ""
|
|
154
|
+
: m.content;
|
|
145
155
|
if (!reason.trim()) continue;
|
|
146
156
|
lines.push(`${magentaBold(`jeo ${jeoMark}`)}`);
|
|
147
157
|
lines.push(...clipBody(reason.trim(), bodyCap));
|