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 +17 -0
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/agent/input-history.ts +79 -0
- package/src/agent/tool-schemas.ts +14 -0
- package/src/ai/providers/anthropic.ts +11 -38
- package/src/ai/providers/openai-responses.ts +3 -13
- package/src/ai/providers/openai.ts +3 -10
- package/src/commands/launch.ts +50 -30
- package/src/commands/update.ts +47 -8
- package/src/tui/app.ts +53 -11
- package/src/tui/components/transcript.ts +23 -13
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
|
@@ -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 =
|
|
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;
|
|
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 ?? "",
|
|
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.
|
|
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
|
-
|
|
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
|
-
}
|
|
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 {
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
|
@@ -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:
|
|
2892
|
-
// uses LaunchTui.showDetail(); this
|
|
2893
|
-
//
|
|
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
|
-
|
|
2904
|
-
|
|
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(
|
|
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,
|
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/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
|
|
|
@@ -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
|
-
|
|
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)`));
|
|
1123
|
+
let body: string[];
|
|
1124
|
+
if (!scrollable) {
|
|
1125
|
+
body = wrapped;
|
|
1100
1126
|
} else {
|
|
1101
|
-
|
|
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:
|
|
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));
|