jeo-code 0.6.0 → 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 CHANGED
@@ -6,6 +6,21 @@ 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
+
9
24
  ## [0.6.0] - 2026-06-16
10
25
  _TUI quality of life: durable input history (↑ recalls past queries across launches), clean `/resume` rendering, and a scrollable mid-turn Ctrl+O panel._
11
26
 
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.
153
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.
154
163
  - **[0.5.16]** (2026-06-16) — `/resume` and Ctrl+O no longer corrupt the TUI — clean screen restore + scrollback expand.
155
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.
156
165
  - **[0.5.14]** (2026-06-16) — `jeo --tmux` live-verification harness — repeatable stability + behavior checks.
157
- - **[0.5.13]** (2026-06-15) — Workflow `/` commands actually run — `/deep-interview`, `/team`, `/ultragoal`, `/ralplan` dispatch by name.
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.
153
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.
154
163
  - **[0.5.16]** (2026-06-16) — `/resume` and Ctrl+O no longer corrupt the TUI — clean screen restore + scrollback expand.
155
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.
156
165
  - **[0.5.14]** (2026-06-16) — `jeo --tmux` live-verification harness — repeatable stability + behavior checks.
157
- - **[0.5.13]** (2026-06-15) — Workflow `/` commands actually run — `/deep-interview`, `/team`, `/ultragoal`, `/ralplan` dispatch by name.
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.
153
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.
154
163
  - **[0.5.16]** (2026-06-16) — `/resume` and Ctrl+O no longer corrupt the TUI — clean screen restore + scrollback expand.
155
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.
156
165
  - **[0.5.14]** (2026-06-16) — `jeo --tmux` live-verification harness — repeatable stability + behavior checks.
157
- - **[0.5.13]** (2026-06-15) — Workflow `/` commands actually run — `/deep-interview`, `/team`, `/ultragoal`, `/ralplan` dispatch by name.
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.
153
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.
154
163
  - **[0.5.16]** (2026-06-16) — `/resume` and Ctrl+O no longer corrupt the TUI — clean screen restore + scrollback expand.
155
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.
156
165
  - **[0.5.14]** (2026-06-16) — `jeo --tmux` live-verification harness — repeatable stability + behavior checks.
157
- - **[0.5.13]** (2026-06-15) — Workflow `/` commands actually run — `/deep-interview`, `/team`, `/ultragoal`, `/ralplan` dispatch by name.
158
166
 
159
167
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
160
168
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -72,6 +72,18 @@ function anthropicSystemBlocks(
72
72
  return blocks;
73
73
  }
74
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
+
75
87
  export function anthropicPayload(
76
88
  messages: Message[],
77
89
  options: CallOptions,
@@ -109,13 +121,24 @@ export function anthropicPayload(
109
121
  last.content[last.content.length - 1] = { ...tail, cache_control: { type: "ephemeral" } };
110
122
  }
111
123
  }
124
+ const maxTokens = options.maxTokens ?? 4000;
125
+ const thinkingBudget = anthropicThinkingBudget(options.reasoningEffort, maxTokens);
112
126
  const payload: Record<string, unknown> = {
113
127
  model,
114
128
  messages: anthropicMessages,
115
- max_tokens: options.maxTokens ?? 4000,
129
+ // Extended thinking requires max_tokens strictly above the thinking budget.
130
+ max_tokens: thinkingBudget !== undefined ? Math.max(maxTokens, thinkingBudget + 1024) : maxTokens,
116
131
  };
117
132
  if (credential.kind === "oauth") payload.metadata = { user_id: createClaudeCloakingUserId() };
118
- if (includeTemperature && options.temperature !== undefined) payload.temperature = options.temperature;
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
+ }
119
142
  if (options.tools?.length) {
120
143
  // NATIVE tool-calling: declare jeo's tools as Anthropic functions. tool_choice
121
144
  // "auto" keeps prose-salvage reachable and lets the model call `done` (declared as
@@ -232,7 +255,7 @@ export const anthropicAdapter: ProviderAdapter = {
232
255
  type?: string;
233
256
  index?: number;
234
257
  content_block?: { type?: string; name?: string };
235
- 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 };
236
259
  message?: { usage?: AnthropicUsage };
237
260
  usage?: { output_tokens?: number };
238
261
  };
@@ -249,6 +272,8 @@ export const anthropicAdapter: ProviderAdapter = {
249
272
  } else if (evt.type === "content_block_delta" && evt.delta?.type === "text_delta" && evt.delta.text) {
250
273
  yieldedAny = true;
251
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);
252
277
  } else if (evt.type === "message_start" && evt.message?.usage) {
253
278
  // Cache only — usage is reported ONCE at message_delta so an accumulating
254
279
  // sink can't double-count input (and a pre-first-chunk retry that replays
@@ -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),
@@ -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
- payload.reasoning = { effort: options.reasoningEffort };
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 {
@@ -175,6 +186,7 @@ export async function codexResponsesCall(messages: Message[], options: CallOptio
175
186
  for await (const data of readSse(response.body)) {
176
187
  const ev = parseResponsesEvent(data);
177
188
  if (ev.delta) out += ev.delta;
189
+ if (ev.reasoningDelta) options.onReasoning?.(ev.reasoningDelta);
178
190
  accumulateResponsesToolCall(toolAcc, ev);
179
191
  if (ev.usage) options.onUsage?.(ev.usage);
180
192
  if (ev.incompleteReason) incompleteReason = ev.incompleteReason;
@@ -202,6 +214,7 @@ export async function* codexResponsesStream(
202
214
  const toolAcc = new Map<number, { name: string; args: string }>();
203
215
  for await (const data of readSse(response.body)) {
204
216
  const ev = parseResponsesEvent(data);
217
+ if (ev.reasoningDelta) options.onReasoning?.(ev.reasoningDelta);
205
218
  if (ev.delta) {
206
219
  yieldedAny = true;
207
220
  yield ev.delta;
@@ -142,6 +142,12 @@ function fastThinkingLevelForModel(modelId: string): ThinkLevel | undefined {
142
142
  const supported = catalogMetadata(modelId)?.thinking ?? [];
143
143
  if (supported.includes("minimal")) return "minimal";
144
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";
145
151
  return undefined;
146
152
  }
147
153
 
@@ -1311,6 +1317,19 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1311
1317
  base.onToolResult?.(tool, success, output);
1312
1318
  },
1313
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
+ });
1314
1333
  /** The Ctrl+O detail block (shared by the prompt-time keypress handler and the
1315
1334
  * mid-turn TUI binding): full last reply + full last tool output. */
1316
1335
  const composeDetailLines = (): string[] => {
@@ -1372,7 +1391,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1372
1391
  const runTurn = async (
1373
1392
  userInput: string,
1374
1393
  useTui: boolean,
1375
- 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 },
1376
1400
  ): Promise<{ done: boolean; steps: number; reply: string; rendered: boolean; usage: string }> => {
1377
1401
  const turnConfig = await readGlobalConfig();
1378
1402
  const activeModel = sessionModel || turnConfig.defaultModel;
@@ -1389,6 +1413,17 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1389
1413
  // AFTER compaction (which mutates history) and consumed by the post-turn
1390
1414
  // persistence block below.
1391
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
+ };
1392
1427
  let result;
1393
1428
  try {
1394
1429
  // Paint the live frame + spinner the INSTANT the turn is accepted, BEFORE the
@@ -1413,17 +1448,24 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1413
1448
  tui?.events().onNotice?.(`(compacted ${compRes.removed} older message${compRes.removed === 1 ? "" : "s"})`);
1414
1449
  }
1415
1450
  beforeLen = history.length;
1451
+ persistedLen = beforeLen; // re-baseline after compaction mutated history
1416
1452
  if (images?.length && catalogMetadata(activeModel)?.images === false) {
1417
1453
  const warn = `! ${activeModel} does not advertise image input — sending the attachment anyway.`;
1418
1454
  if (tui) tui.events().onNotice?.(warn);
1419
1455
  else console.log(warn);
1420
1456
  }
1421
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
1422
1460
  // Keep the submitted query in scrollback: the prompt that STARTS a turn shows
1423
1461
  // only as the transient HUD turn-title otherwise, which vanishes when the live
1424
1462
  // frame clears at turn-end — so the conversation transcript lost every user
1425
1463
  // prompt. Flush a `user` card (same surface as a mid-turn steer) so it persists.
1426
- if (tui && userInput.trim()) tui.flushUserCard(userInput);
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);
1427
1469
  tui?.setContextUsage(historyTokens(history), contextTokens);
1428
1470
 
1429
1471
  // Per-turn steering inbox (gjc parity): additional queries typed mid-turn land
@@ -1548,7 +1590,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1548
1590
  maxTokens: sessionThinking ? thinkingMaxTokens(sessionThinking) : undefined,
1549
1591
  signal: ac.signal,
1550
1592
  steer: drainSteer,
1551
- events: wrapEvents({ ...withToolDetailCapture(tui ? tui.events() : streamEvents), onBeforeDone }, opik),
1593
+ events: wrapEvents(withStepPersistence({ ...withToolDetailCapture(tui ? tui.events() : streamEvents), onBeforeDone }, persistTurnTail), opik),
1552
1594
  });
1553
1595
  if (result.done && looksLikeSkillEcho(result.doneReason ?? "", resolvedSkills)) {
1554
1596
  history.push({
@@ -1603,13 +1645,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1603
1645
  || (result.done
1604
1646
  ? `(done in ${result.steps} step${result.steps === 1 ? "" : "s"} — the model returned no summary)`
1605
1647
  : `(reached the ${result.steps}-step limit without signaling done)`);
1606
- // Full-fidelity persistence: append every message the engine added this turn
1607
- // (user prompt + intermediate tool-call/tool-result turns), then the final reply.
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.
1608
1652
  try {
1609
- if (sessionId) {
1610
- // One batched fs append for the whole turn (was: one awaited append per message).
1611
- await appendMessages(sessionId, history.slice(beforeLen), cwd);
1612
- }
1653
+ await persistTurnTail();
1613
1654
  history.push({ role: "assistant", content: reply });
1614
1655
  if (sessionId) await appendMessage(sessionId, { role: "assistant", content: reply }, cwd);
1615
1656
  if (tui) tui.finish(reply);
@@ -1729,7 +1770,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1729
1770
  console.log(`▶ Running skill: ${inv.skill.name}${inv.intent ? ` — ${inv.intent}` : ""}`);
1730
1771
  }
1731
1772
  const task = buildSkillTask(inv.skill, inv.intent, inv.invokedAs);
1732
- const { reply, rendered, usage } = await runTurn(task, useOneShotTui);
1773
+ const { reply, rendered, usage } = await runTurn(task, useOneShotTui, undefined, { userCard: null });
1733
1774
  if (!rendered) console.log(stripMarkdown(renderMarkdownTables(reply)) + usage);
1734
1775
  else if (usage) console.log(usage.trim());
1735
1776
  };
@@ -1837,7 +1878,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1837
1878
  // starts — skill name, resolved SKILL.md path, and the prompt size.
1838
1879
  {
1839
1880
  const card = formatForgeBox(
1840
- { title: "[skill]", lines: skillInvocationCard(skill) },
1881
+ { title: "[skill]", lines: skillInvocationCard(skill, intent) },
1841
1882
  { width: Math.min(100, Math.max(40, (process.stdout.columns ?? 80) - 2)), unicode: supportsUnicode(), paint: accentPaint(uiTheme), paintShadow: accentShadowPaint(uiTheme), color: uiTheme.color },
1842
1883
  );
1843
1884
  logLines(card);
@@ -1918,7 +1959,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1918
1959
  // final reply is the skill's result.
1919
1960
  if (!useTui) console.log(`▶ Running skill: ${skill.name}${intent ? ` — ${intent}` : ""}`);
1920
1961
  const task = buildSkillTask(skill, intent, invokedAs);
1921
- const { reply, rendered, usage } = await runTurn(task, useTui);
1962
+ const { reply, rendered, usage } = await runTurn(task, useTui, undefined, { userCard: null });
1922
1963
  if (!rendered) console.log(`jeo> ${stripMarkdown(renderMarkdownTables(reply))}${usage}`);
1923
1964
  else if (usage) console.log(usage.trim());
1924
1965
  }
@@ -2299,6 +2340,13 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2299
2340
  }
2300
2341
  };
2301
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;
2302
2350
 
2303
2351
  // Inline boxed-footer rendering with a FIXED reservation (the "@-mention typing
2304
2352
  // pushes the box down" fix). The footer reserves its full `footerRows` height
@@ -2450,6 +2498,44 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2450
2498
  const preview = (slash.length ? slash : args).map(l => chalk.gray(truncateAnsi(l, cols)));
2451
2499
  return [statusBarLine(cols), "", ...input, ...preview].slice(0, footerRows);
2452
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
+ };
2453
2539
  const drawFooter = (lines: string[]) => {
2454
2540
  if (!previewArmed || footerRendered === 0) return;
2455
2541
  // ALWAYS paint exactly footerRendered rows so the reservation is fully covered
@@ -2896,24 +2982,41 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2896
2982
  return;
2897
2983
  }
2898
2984
  if (!previewArmed || pickerActive) return;
2899
- // Ctrl+O: expand the last assistant response into scrollback for a full,
2900
- // scrollable read. The live-turn TUI path uses LaunchTui.showDetail(); this
2901
- // idle-prompt path prints the same content above a cleanly re-armed footer.
2902
- // (Cmd+O is intercepted by macOS/terminal and never reaches the app.)
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.)
2903
2989
  if (key?.ctrl && key.name === "o") {
2990
+ if (promptHistoryLines) {
2991
+ promptHistoryLines = null; // fold
2992
+ drawFooter(previewLines(typedLine, navIdx));
2993
+ return;
2994
+ }
2904
2995
  const detail = composeDetailLines();
2905
2996
  if (detail.length === 0) return;
2906
- // Expand the last response into scrollback CLEANLY instead of cramming it
2907
- // into the ~10-row footer reservation (which clipped long/CJK content and
2908
- // could garble the box). Disarm → print full detail → re-arm + repaint is
2909
- // the same path the resize handler/main loop use, so the input box and the
2910
- // typed draft restore without corruption.
2911
- disarmPreview();
2912
- logLines(detail);
2913
- armPreview();
2914
- drawFooter(previewLines(typedLine, navIdx));
2997
+ promptHistoryLines = detail;
2998
+ promptHistoryScroll = 0;
2999
+ drawFooter(historyPreviewLines(detail));
2915
3000
  return;
2916
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
+ }
2917
3020
  // Ctrl+V: attach a clipboard IMAGE to the next message. Terminal text paste
2918
3021
  // never arrives as a ctrl+v keypress (it streams as plain stdin data), so this
2919
3022
  // binding is image-only; when the clipboard holds no image it's a silent no-op.
@@ -2956,7 +3059,13 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2956
3059
  if (!previewArmed) return;
2957
3060
  try {
2958
3061
  if (key && (key.name === "return" || key.name === "enter")) {
2959
- 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));
2960
3069
  return;
2961
3070
  }
2962
3071
  // Arrow up/down: move the highlight over the slash keyword preview list.
@@ -3016,7 +3125,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3016
3125
  try {
3017
3126
  disarmPreview();
3018
3127
  armPreview();
3019
- drawFooter(previewLines(typedLine, navIdx));
3128
+ drawFooter(promptHistoryLines ? historyPreviewLines(promptHistoryLines) : previewLines(typedLine, navIdx));
3020
3129
  } catch { /* ignore resize render races */ }
3021
3130
  };
3022
3131
  process.stdout.on("resize", idleResizeHandler);
@@ -3055,6 +3164,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3055
3164
  // window where the box is gone and keystrokes echo nowhere (the "no response
3056
3165
  // after the result" gap). readline's own echo stays gated while armed.
3057
3166
  armPreview();
3167
+ promptHistoryLines = null; // each fresh prompt starts with the Ctrl+O panel closed
3058
3168
  if (prefilledLine) {
3059
3169
  rli.line = prefilledLine;
3060
3170
  rli.cursor = prefilledLine.length;
@@ -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
@@ -766,6 +766,15 @@ export class LaunchTui {
766
766
  // (e.g. "rate limited (HTTP 429) — auto-retry #2 in 4s") instead of an opaque
767
767
  // ever-growing "calling model (18.4s)…".
768
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)…`;
769
778
  return `calling model (${this.footer.model}) (${elapsed}s)…`;
770
779
  }
771
780
  if (running) {