jeo-code 0.5.15 → 0.6.0

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,23 @@ 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.0] - 2026-06-16
10
+ _TUI quality of life: durable input history (↑ recalls past queries across launches), clean `/resume` rendering, and a scrollable mid-turn Ctrl+O panel._
11
+
12
+ ### Added
13
+ - **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`).
14
+
15
+ ### Fixed
16
+ - **`/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.
17
+ - **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.
18
+
19
+ ## [0.5.16] - 2026-06-16
20
+ _`/resume` and Ctrl+O no longer corrupt the TUI — clean screen restore + scrollback expand._
21
+
22
+ ### Fixed
23
+ - **`/resume` corrupted the screen on a TTY.** After picking a session the resumed transcript was dumped on top of whatever was on screen (picker remnants, the prior conversation, the live input frame), so replayed ANSI/forge boxes from the old session collided with the live layout. Resume now wipes the screen + scrollback and re-renders the welcome banner BEFORE replaying the transcript — the same proven path `/clear` uses — so the restored view is a single, intact screen (verified live: one input box, one status bar, no picker remnants).
24
+ - **Ctrl+O at the prompt crammed the last response into the ~10-row footer**, clipping long/CJK content with "… N more line(s)" and risking a garbled box. Ctrl+O now expands the full last assistant response into scrollback (clean `disarm → print → re-arm` path), so it is fully scrollable and the input box + typed draft restore without corruption. Removed the now-dead footer history-panel machinery (`promptHistoryLines`, `historyPreviewLines`).
25
+
9
26
  ## [0.5.15] - 2026-06-16
10
27
  _`jeo update` now actually upgrades — bare command installs the latest release instead of just printing a manual command._
11
28
 
package/README.ja.md CHANGED
@@ -150,11 +150,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
150
150
  ## 変更履歴 (Changelog)
151
151
 
152
152
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
153
+ - **[0.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
+ - **[0.5.16]** (2026-06-16) — `/resume` and Ctrl+O no longer corrupt the TUI — clean screen restore + scrollback expand.
153
155
  - **[0.5.15]** (2026-06-16) — `jeo update` now actually upgrades — bare command installs the latest release instead of just printing a manual command.
154
156
  - **[0.5.14]** (2026-06-16) — `jeo --tmux` live-verification harness — repeatable stability + behavior checks.
155
157
  - **[0.5.13]** (2026-06-15) — Workflow `/` commands actually run — `/deep-interview`, `/team`, `/ultragoal`, `/ralplan` dispatch by name.
156
- - **[0.5.12]** (2026-06-15) — Yellow status animation while a process runs, and elapsed `(Nms)` on every completed tool card.
157
- - **[0.5.11]** (2026-06-15) — Backspace on an empty prompt line no longer quits jeo.
158
158
 
159
159
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
160
160
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -150,11 +150,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
150
150
  ## 변경 이력 (Changelog)
151
151
 
152
152
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
153
+ - **[0.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
+ - **[0.5.16]** (2026-06-16) — `/resume` and Ctrl+O no longer corrupt the TUI — clean screen restore + scrollback expand.
153
155
  - **[0.5.15]** (2026-06-16) — `jeo update` now actually upgrades — bare command installs the latest release instead of just printing a manual command.
154
156
  - **[0.5.14]** (2026-06-16) — `jeo --tmux` live-verification harness — repeatable stability + behavior checks.
155
157
  - **[0.5.13]** (2026-06-15) — Workflow `/` commands actually run — `/deep-interview`, `/team`, `/ultragoal`, `/ralplan` dispatch by name.
156
- - **[0.5.12]** (2026-06-15) — Yellow status animation while a process runs, and elapsed `(Nms)` on every completed tool card.
157
- - **[0.5.11]** (2026-06-15) — Backspace on an empty prompt line no longer quits jeo.
158
158
 
159
159
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
160
160
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -150,11 +150,11 @@ Required npm token permissions (repository secret `NPM_TOKEN`):
150
150
  ## Changelog
151
151
 
152
152
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
153
+ - **[0.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
+ - **[0.5.16]** (2026-06-16) — `/resume` and Ctrl+O no longer corrupt the TUI — clean screen restore + scrollback expand.
153
155
  - **[0.5.15]** (2026-06-16) — `jeo update` now actually upgrades — bare command installs the latest release instead of just printing a manual command.
154
156
  - **[0.5.14]** (2026-06-16) — `jeo --tmux` live-verification harness — repeatable stability + behavior checks.
155
157
  - **[0.5.13]** (2026-06-15) — Workflow `/` commands actually run — `/deep-interview`, `/team`, `/ultragoal`, `/ralplan` dispatch by name.
156
- - **[0.5.12]** (2026-06-15) — Yellow status animation while a process runs, and elapsed `(Nms)` on every completed tool card.
157
- - **[0.5.11]** (2026-06-15) — Backspace on an empty prompt line no longer quits jeo.
158
158
 
159
159
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
160
160
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -150,11 +150,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
150
150
  ## 更新日志 (Changelog)
151
151
 
152
152
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
153
+ - **[0.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
+ - **[0.5.16]** (2026-06-16) — `/resume` and Ctrl+O no longer corrupt the TUI — clean screen restore + scrollback expand.
153
155
  - **[0.5.15]** (2026-06-16) — `jeo update` now actually upgrades — bare command installs the latest release instead of just printing a manual command.
154
156
  - **[0.5.14]** (2026-06-16) — `jeo --tmux` live-verification harness — repeatable stability + behavior checks.
155
157
  - **[0.5.13]** (2026-06-15) — Workflow `/` commands actually run — `/deep-interview`, `/team`, `/ultragoal`, `/ralplan` dispatch by name.
156
- - **[0.5.12]** (2026-06-15) — Yellow status animation while a process runs, and elapsed `(Nms)` on every completed tool card.
157
- - **[0.5.11]** (2026-06-15) — Backspace on an empty prompt line no longer quits jeo.
158
158
 
159
159
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
160
160
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.5.15",
3
+ "version": "0.6.0",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -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
 
@@ -198,25 +199,6 @@ function emptyCompletionError(stopReason: string | undefined): Error {
198
199
  return new Error(`Anthropic returned no content${stopReason ? ` (stop_reason=${stopReason})` : ""}${hint}.`);
199
200
  }
200
201
 
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
202
  export const anthropicAdapter: ProviderAdapter = {
221
203
  name: "anthropic",
222
204
  supportsNativeTools: true,
@@ -225,7 +207,11 @@ export const anthropicAdapter: ProviderAdapter = {
225
207
  const result = (await response.json()) as { content: { type: string; text?: string; name?: string; input?: unknown }[]; stop_reason?: string; usage?: AnthropicUsage };
226
208
  if (result.usage) options.onUsage?.({ inputTokens: totalInputTokens(result.usage), outputTokens: result.usage.output_tokens });
227
209
  // Prefer a native tool call (re-serialized to canonical JSON) over any stray text.
228
- const toolCall = serializeAnthropicToolUse(result.content);
210
+ const toolCall = serializeToolCalls(
211
+ result.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
+ );
229
215
  if (toolCall) return toolCall;
230
216
  const text = result.content.find(c => c.type === "text")?.text ?? "";
231
217
  if (!text) throw emptyCompletionError(result.stop_reason);
@@ -240,7 +226,7 @@ export const anthropicAdapter: ProviderAdapter = {
240
226
  // Native tool_use streams as content_block_start (name) + input_json_delta fragments,
241
227
  // never as text_delta — accumulate per block index, then re-serialize to canonical
242
228
  // JSON and yield it once at the end (concatenation still equals call()).
243
- const toolBlocks = new Map<number, { name: string; json: string }>();
229
+ const toolBlocks = new Map<number, { name: string; args: string }>();
244
230
  for await (const data of readSse(response.body)) {
245
231
  let evt: {
246
232
  type?: string;
@@ -256,10 +242,10 @@ export const anthropicAdapter: ProviderAdapter = {
256
242
  continue;
257
243
  }
258
244
  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 ?? "", json: "" });
245
+ toolBlocks.set(evt.index, { name: evt.content_block.name ?? "", args: "" });
260
246
  } else if (evt.type === "content_block_delta" && evt.delta?.type === "input_json_delta" && typeof evt.index === "number") {
261
247
  const b = toolBlocks.get(evt.index);
262
- if (b) b.json += evt.delta.partial_json ?? "";
248
+ if (b) b.args += evt.delta.partial_json ?? "";
263
249
  } else if (evt.type === "content_block_delta" && evt.delta?.type === "text_delta" && evt.delta.text) {
264
250
  yieldedAny = true;
265
251
  yield evt.delta.text;
@@ -273,21 +259,8 @@ export const anthropicAdapter: ProviderAdapter = {
273
259
  if (evt.usage) options.onUsage?.({ inputTokens: cachedInput, outputTokens: evt.usage.output_tokens });
274
260
  }
275
261
  }
276
- if (toolBlocks.size > 0) {
277
- const calls = [...toolBlocks.values()]
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
- }
262
+ const envelope = serializeAccumulatedToolCalls(toolBlocks);
263
+ if (envelope) { yieldedAny = true; yield envelope; }
291
264
  if (!yieldedAny) throw emptyCompletionError(stopReason);
292
265
  },
293
266
  };
@@ -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 { serializeToolCalls } from "../../agent/tool-schemas";
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
 
@@ -154,16 +154,6 @@ function accumulateResponsesToolCall(acc: Map<number, { name: string; args: stri
154
154
  }
155
155
  }
156
156
 
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
157
 
168
158
  /** Round-5 #1: no-text completions surface their cause instead of returning "". */
169
159
  function emptyCompletionError(reason: string | undefined): Error {
@@ -191,7 +181,7 @@ export async function codexResponsesCall(messages: Message[], options: CallOptio
191
181
  if (ev.error) throw new Error(`OpenAI Codex response failed: ${ev.error}`);
192
182
  }
193
183
  // Prefer a native tool call (re-serialized to canonical JSON) over any stray text.
194
- const envelope = serializeResponsesToolCalls(toolAcc);
184
+ const envelope = serializeAccumulatedToolCalls(toolAcc);
195
185
  if (envelope) return envelope;
196
186
  if (!out) throw emptyCompletionError(incompleteReason);
197
187
  return out;
@@ -222,7 +212,7 @@ export async function* codexResponsesStream(
222
212
  if (ev.error) throw new Error(`OpenAI Codex response failed: ${ev.error}`);
223
213
  }
224
214
  // Native tool calls have no output_text deltas — yield the re-serialized envelope once.
225
- const envelope = serializeResponsesToolCalls(toolAcc);
215
+ const envelope = serializeAccumulatedToolCalls(toolAcc);
226
216
  if (envelope) { yieldedAny = true; yield envelope; }
227
217
  if (!yieldedAny) throw emptyCompletionError(incompleteReason);
228
218
  }
@@ -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
- if (toolAcc.size > 0) {
133
- const calls = [...toolAcc.values()].map(b => {
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
  };
@@ -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
 
@@ -485,6 +486,9 @@ interface AbortHarnessOptions {
485
486
  * Without this hook the byte would be swallowed into the buffered input queue,
486
487
  * which is why Ctrl+O historically "did nothing" while the TUI owned stdin. */
487
488
  onDetailKey?: () => void;
489
+ /** Invoked when an arrow / PageUp / PageDown key arrives mid-turn — scrolls the
490
+ * open Ctrl+O detail panel. dir -1 = up/back, +1 = down/forward; page = full jump. */
491
+ onScrollKey?: (dir: -1 | 1, page: boolean) => void;
488
492
  /** Invoked with printable keyboard input received while the live turn owns stdin. */
489
493
  onBufferedInput?: (chunk: string) => void;
490
494
  /** True while the input queue is inside a bracketed paste (mid-paste chunks
@@ -712,6 +716,14 @@ export function createInFlightAbortHarness(opts: AbortHarnessOptions = {}): InFl
712
716
  opts.onDetailKey?.();
713
717
  return;
714
718
  }
719
+ // Arrow / PageUp / PageDown — scroll the open Ctrl+O detail panel. Exact-match
720
+ // (whole chunk) like Ctrl+O so embedded sequences in pasted/streamed data don't
721
+ // trigger; bracketed paste already returned above. When the panel is closed the
722
+ // hook is a no-op, so these keys stay inert mid-turn as before.
723
+ if (text === "\u001b[A") { opts.onScrollKey?.(-1, false); return; }
724
+ if (text === "\u001b[B") { opts.onScrollKey?.(1, false); return; }
725
+ if (text === "\u001b[5~") { opts.onScrollKey?.(-1, true); return; }
726
+ if (text === "\u001b[6~") { opts.onScrollKey?.(1, true); return; }
715
727
  const escAt = text.indexOf("\u001b");
716
728
  const sigintAt = text.indexOf("\u0003");
717
729
  const controlAt =
@@ -1440,6 +1452,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1440
1452
  if (lines.length === 0) return;
1441
1453
  tui.showDetail(lines);
1442
1454
  },
1455
+ onScrollKey: (dir, page) => tui?.scrollDetail(dir, page),
1443
1456
  onBufferedInput: chunk => {
1444
1457
  if (!tui) return;
1445
1458
  // gjc-style mid-turn steering: a typed Enter (outside a bracketed paste)
@@ -2061,6 +2074,18 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2061
2074
  completer: (line: string) => readlineCompleter(line, completionContext()),
2062
2075
  });
2063
2076
  activeRl = rl; // wire the input filter's empty-line backspace guard to the live buffer
2077
+ // Cross-launch input history: hydrate readline's ↑/↓ ring with prompts from
2078
+ // previous sessions in this workspace, so a fresh launch can recall "이전에
2079
+ // 사용한 쿼리" — not just lines typed in the current run. readline keeps history
2080
+ // newest-first, which is exactly the order loadInputHistory returns.
2081
+ if (process.stdin.isTTY) {
2082
+ const rliHist = rl as unknown as { history?: string[] };
2083
+ if (Array.isArray(rliHist.history)) {
2084
+ for (const entry of loadInputHistory(cwd)) {
2085
+ if (!rliHist.history.includes(entry)) rliHist.history.push(entry);
2086
+ }
2087
+ }
2088
+ }
2064
2089
  const promptStdin = process.stdin as typeof process.stdin & { isRaw?: boolean; setRawMode?(raw: boolean): void };
2065
2090
  const promptWasRaw = !!promptStdin.isRaw;
2066
2091
  let promptRawChanged = false;
@@ -2274,7 +2299,6 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2274
2299
  }
2275
2300
  };
2276
2301
  let previewPending = false;
2277
- let promptHistoryLines: string[] | null = null;
2278
2302
 
2279
2303
  // Inline boxed-footer rendering with a FIXED reservation (the "@-mention typing
2280
2304
  // pushes the box down" fix). The footer reserves its full `footerRows` height
@@ -2426,22 +2450,6 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2426
2450
  const preview = (slash.length ? slash : args).map(l => chalk.gray(truncateAnsi(l, cols)));
2427
2451
  return [statusBarLine(cols), "", ...input, ...preview].slice(0, footerRows);
2428
2452
  };
2429
- const historyPreviewLines = (detail: string[]): string[] => {
2430
- const cols = Math.max(24, (process.stdout.columns ?? 80) - 1);
2431
- const title = `${uiAccent("history")} ${chalk.dim("· Ctrl+O closes")}`;
2432
- const budget = Math.max(0, footerRows - 2);
2433
- const physical = detail.flatMap(line => line.split("\n")).map(line => truncateAnsi(line, cols));
2434
- let body = physical;
2435
- if (physical.length > budget) {
2436
- const keep = Math.max(0, budget - 1);
2437
- body = physical.slice(0, keep);
2438
- body.push(chalk.gray(`… ${physical.length - keep} more line(s)`));
2439
- } else {
2440
- body = physical.slice(0, budget);
2441
- }
2442
- footerCursor = { row: Math.min(1, footerRows - 1), col: 1 };
2443
- return [statusBarLine(cols), title, ...body].slice(0, footerRows);
2444
- };
2445
2453
  const drawFooter = (lines: string[]) => {
2446
2454
  if (!previewArmed || footerRendered === 0) return;
2447
2455
  // ALWAYS paint exactly footerRendered rows so the reservation is fully covered
@@ -2888,23 +2896,24 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2888
2896
  return;
2889
2897
  }
2890
2898
  if (!previewArmed || pickerActive) return;
2891
- // Ctrl+O: toggle a reversible history/detail panel. The live-turn TUI path
2892
- // uses LaunchTui.showDetail(); this idle-prompt path paints the same content
2893
- // into the fixed footer reservation, so the second Ctrl+O can close it.
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.
2894
2902
  // (Cmd+O is intercepted by macOS/terminal and never reaches the app.)
2895
2903
  if (key?.ctrl && key.name === "o") {
2896
- if (promptHistoryLines) {
2897
- promptHistoryLines = null;
2898
- drawFooter(previewLines(typedLine, navIdx));
2899
- return;
2900
- }
2901
2904
  const detail = composeDetailLines();
2902
2905
  if (detail.length === 0) return;
2903
- promptHistoryLines = detail;
2904
- drawFooter(historyPreviewLines(detail));
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));
2905
2915
  return;
2906
2916
  }
2907
- if (promptHistoryLines) promptHistoryLines = null;
2908
2917
  // Ctrl+V: attach a clipboard IMAGE to the next message. Terminal text paste
2909
2918
  // never arrives as a ctrl+v keypress (it streams as plain stdin data), so this
2910
2919
  // binding is image-only; when the clipboard holds no image it's a silent no-op.
@@ -2947,7 +2956,6 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2947
2956
  if (!previewArmed) return;
2948
2957
  try {
2949
2958
  if (key && (key.name === "return" || key.name === "enter")) {
2950
- promptHistoryLines = null;
2951
2959
  drawFooter([]);
2952
2960
  return;
2953
2961
  }
@@ -3008,7 +3016,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3008
3016
  try {
3009
3017
  disarmPreview();
3010
3018
  armPreview();
3011
- drawFooter(promptHistoryLines ? historyPreviewLines(promptHistoryLines) : previewLines(typedLine, navIdx));
3019
+ drawFooter(previewLines(typedLine, navIdx));
3012
3020
  } catch { /* ignore resize render races */ }
3013
3021
  };
3014
3022
  process.stdout.on("resize", idleResizeHandler);
@@ -3064,6 +3072,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3064
3072
  if (rawText.includes("\u0003")) forceExitFromCtrlC();
3065
3073
  const raw = rawText.trim();
3066
3074
  disarmPreview();
3075
+ // Persist the submitted line so ↑ recalls it on a future launch (best-effort).
3076
+ if (raw && process.stdin.isTTY) appendInputHistory(cwd, raw);
3067
3077
  // Pasted batch command: echo what is about to run (with the remaining queue
3068
3078
  // depth) so a multi-line paste reads as a visible, ordered script.
3069
3079
  if (promptServedFromPaste && raw) {
@@ -3221,6 +3231,16 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3221
3231
  if (rli.history[0] !== p) rli.history.unshift(p);
3222
3232
  }
3223
3233
  }
3234
+ // Clean restore: wipe the screen + scrollback BEFORE replaying the
3235
+ // transcript so it can't collide with picker remnants, the prior
3236
+ // conversation, or the live input frame — the "/resume corrupts the
3237
+ // TUI" fix. Same proven path as /clear; re-render the welcome banner
3238
+ // so the resumed view reads like a fresh, intact screen.
3239
+ if (process.stdout.isTTY) {
3240
+ disarmPreview();
3241
+ process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
3242
+ console.log(renderWelcome(welcomeData).join("\n"));
3243
+ }
3224
3244
  const sep = "─".repeat(Math.min(48, Math.max(20, (process.stdout.columns ?? 80) - 1)));
3225
3245
  logLines([
3226
3246
  sep,
@@ -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. jeo runs on Bun (see the `#!/usr/bin/env bun`
63
- // shebang), so Bun is always present; `@<version>` (default `latest`) forces the
64
- // newest publish even if a stale global is cached.
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 proc = Bun.spawnSync(["bun", "install", "-g", target], {
67
- stdout: "inherit",
68
- stderr: "inherit",
69
- });
70
- return { success: proc.success };
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/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 panel with
547
- * the full last reply / tool output, second press closes it and returns to the
548
- * normal activity frame. Unlike the old scrollback dump, this is reversible. */
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
 
@@ -1085,20 +1105,42 @@ export class LaunchTui {
1085
1105
  const inner = Math.max(10, boxWidth - 2);
1086
1106
  const accent = this.theme.color ? accentPaint(this.theme) : (s: string) => s;
1087
1107
  const dim = this.theme.color ? chalk.dim : (s: string) => s;
1088
- const title = `${accent("history")} ${dim("· Ctrl+O closes")}`;
1089
1108
  const wrapped = this.historyLines.flatMap(line => {
1090
1109
  const physical = line.split("\n");
1091
1110
  return physical.flatMap(part => (visibleWidth(part) <= inner ? [part] : wrapTextWithAnsi(part, inner)));
1092
1111
  });
1112
+ const bodyLimit = Math.max(1, maxRows - 4); // box borders (2) + title + divider
1113
+ const scrollable = wrapped.length > bodyLimit;
1114
+ // Window capacity at the bottom (worst case: both ↑ and ↓ indicators take a row),
1115
+ // so the last line is always reachable by scrolling.
1116
+ const cap = scrollable ? Math.max(1, bodyLimit - 2) : bodyLimit;
1117
+ this.historyPageSize = cap;
1118
+ this.historyMaxScroll = Math.max(0, wrapped.length - cap);
1119
+ if (this.historyScroll > this.historyMaxScroll) this.historyScroll = this.historyMaxScroll;
1120
+ const hint = scrollable ? "· ↑↓/PgUp/PgDn scroll · Ctrl+O closes" : "· Ctrl+O closes";
1121
+ const title = `${accent("history")} ${dim(hint)}`;
1093
1122
  const header = [title, "DIVIDER"];
1094
- const bodyLimit = Math.max(0, maxRows - 2 - header.length);
1095
- let body = wrapped;
1096
- if (wrapped.length > bodyLimit) {
1097
- const keep = Math.max(0, bodyLimit - 1);
1098
- body = wrapped.slice(0, keep);
1099
- body.push(dim(`… ${wrapped.length - keep} more line(s)`));
1123
+ let body: string[];
1124
+ if (!scrollable) {
1125
+ body = wrapped;
1100
1126
  } else {
1101
- body = wrapped.slice(0, bodyLimit);
1127
+ // Window the content and show ↑/↓ counters instead of dropping the tail at
1128
+ // "… N more": every line is reachable via scrollDetail (arrows / PgUp-PgDn).
1129
+ const start = this.historyScroll;
1130
+ const above = start;
1131
+ const reserveTop = above > 0 ? 1 : 0;
1132
+ let innerLimit = Math.max(1, bodyLimit - reserveTop - 1);
1133
+ let win = wrapped.slice(start, start + innerLimit);
1134
+ let below = wrapped.length - (start + win.length);
1135
+ if (below === 0) {
1136
+ innerLimit = Math.max(1, bodyLimit - reserveTop);
1137
+ win = wrapped.slice(start, start + innerLimit);
1138
+ below = wrapped.length - (start + win.length);
1139
+ }
1140
+ body = [];
1141
+ if (above > 0) body.push(dim(`↑ ${above} more above`));
1142
+ body.push(...win);
1143
+ if (below > 0) body.push(dim(`↓ ${below} more below`));
1102
1144
  }
1103
1145
  return boxBlock([...header, ...body], boxWidth, {
1104
1146
  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: one or more JSON tool calls (compact ledger lines) or a prose reply.
110
- // Handles BOTH the single `{tool,arguments}` form AND the batched `{tools:[...]}`
111
- // form the batch case previously parsed to no `tool` field, fell through, and
112
- // dumped the raw JSON object into the transcript (the "/resume shows JSON" bug).
113
- let parsed: { tool?: unknown; tools?: unknown; arguments?: unknown } | null = null;
114
- try {
115
- const p: unknown = JSON.parse(m.content);
116
- if (p && typeof p === "object") parsed = p as { tool?: unknown; tools?: unknown; arguments?: unknown };
117
- } catch { /* prose reply */ }
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
- // A lone `done` (show its reason) or a plain prose reply.
142
- const reason = parsed
143
- ? String((parsed.arguments as { reason?: unknown } | undefined)?.reason ?? "")
144
- : m.content;
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));