jeo-code 0.6.27 → 0.6.29
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 +26 -0
- package/README.ja.md +2 -6
- package/README.ko.md +2 -6
- package/README.md +2 -6
- package/README.zh.md +2 -6
- package/package.json +1 -1
- package/src/agent/compaction.ts +10 -1
- package/src/agent/engine.ts +62 -16
- package/src/agent/loop.ts +3 -0
- package/src/ai/model-catalog.ts +12 -5
- package/src/ai/model-manager.ts +1 -0
- package/src/ai/providers/anthropic.ts +121 -21
- package/src/ai/providers/antigravity.ts +6 -0
- package/src/ai/providers/errors.ts +18 -0
- package/src/ai/providers/gemini.ts +84 -28
- package/src/ai/providers/openai-compatible-catalog.ts +10 -4
- package/src/ai/providers/openai-responses.ts +76 -19
- package/src/ai/types.ts +55 -2
- package/src/commands/launch.ts +90 -22
- package/src/tui/app.ts +38 -6
- package/src/tui/components/ascii-art.ts +27 -31
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import type { Credential } from "../../auth";
|
|
14
14
|
import type { CallOptions, Message } from "../types";
|
|
15
15
|
import { readSse } from "../sse";
|
|
16
|
-
import { providerHttpError } from "./errors";
|
|
16
|
+
import { providerHttpError, fetchWithArtifactFailSafe } from "./errors";
|
|
17
17
|
import { serializeAccumulatedToolCalls } from "../../agent/tool-schemas";
|
|
18
18
|
|
|
19
19
|
export const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
|
|
@@ -35,28 +35,64 @@ export function extractChatgptAccountId(token: string): string | undefined {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
|
|
39
|
+
type ResponsesInputItem = Record<string, unknown>;
|
|
40
|
+
|
|
41
|
+
/** True when an assistant turn can replay stateless reasoning: it has structured toolUse AND
|
|
42
|
+
* a same-model OpenAI reasoning item (id + encrypted_content) captured this session. */
|
|
43
|
+
export function responsesNativizable(m: Message, modelKey: string): boolean {
|
|
44
|
+
return !!m.toolUse?.length
|
|
45
|
+
&& !!m.reasoningArtifacts?.some(a => a.provider === "openai" && a.model === modelKey && !!a.itemId && !!a.encrypted);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Build the Responses `input` array, reconstructing native reasoning + function_call +
|
|
49
|
+
* function_call_output items for same-model OpenAI turns (stateless reasoning replay).
|
|
50
|
+
* stripArtifacts (fail-safe) or a non-matching model ⇒ the plain output_text/input_text shape. */
|
|
51
|
+
export function buildResponsesInput(messages: Message[], modelKey: string, stripArtifacts = false): ResponsesInputItem[] {
|
|
52
|
+
const nonSystem = messages.filter(m => m.role !== "system");
|
|
53
|
+
const items: ResponsesInputItem[] = [];
|
|
54
|
+
const plain = (m: Message): ResponsesInputItem => ({
|
|
55
|
+
role: m.role,
|
|
56
|
+
content: [
|
|
57
|
+
{ type: m.role === "assistant" ? "output_text" : "input_text", text: m.content },
|
|
58
|
+
...(m.role !== "assistant" && m.images?.length
|
|
59
|
+
? m.images.map(img => ({ type: "input_image", image_url: `data:${img.mediaType};base64,${img.data}` }))
|
|
60
|
+
: []),
|
|
61
|
+
],
|
|
62
|
+
});
|
|
63
|
+
nonSystem.forEach((m, i) => {
|
|
64
|
+
if (!stripArtifacts && m.role === "assistant" && responsesNativizable(m, modelKey)) {
|
|
65
|
+
for (const a of m.reasoningArtifacts!) {
|
|
66
|
+
if (a.provider === "openai" && a.model === modelKey && a.itemId && a.encrypted) {
|
|
67
|
+
items.push({ type: "reasoning", id: a.itemId, encrypted_content: a.encrypted, summary: [] });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (const tu of m.toolUse!) {
|
|
71
|
+
items.push({ type: "function_call", call_id: tu.id, name: tu.tool, arguments: JSON.stringify(tu.arguments) });
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (!stripArtifacts && m.role === "user" && m.toolResults?.length && i > 0
|
|
76
|
+
&& nonSystem[i - 1].role === "assistant" && responsesNativizable(nonSystem[i - 1], modelKey)) {
|
|
77
|
+
for (const tr of m.toolResults) items.push({ type: "function_call_output", call_id: tr.id, output: tr.output });
|
|
78
|
+
if (m.toolResultExtra) items.push({ role: "user", content: [{ type: "input_text", text: m.toolResultExtra }] });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
items.push(plain(m));
|
|
82
|
+
});
|
|
83
|
+
return items;
|
|
84
|
+
}
|
|
38
85
|
/** Build the Codex Responses request (url + headers + body) for an OAuth credential. */
|
|
39
86
|
export function codexResponsesRequest(
|
|
40
87
|
messages: Message[],
|
|
41
88
|
options: CallOptions,
|
|
42
89
|
credential: Credential,
|
|
90
|
+
stripArtifacts = false,
|
|
43
91
|
): { url: string; headers: Record<string, string>; body: string } {
|
|
44
92
|
const model = options.model.startsWith("openai/") ? options.model.slice(7) : options.model;
|
|
45
93
|
const token = credential.kind === "none" ? "" : credential.token;
|
|
46
94
|
const systemPrompt = options.systemPrompt ?? messages.find(m => m.role === "system")?.content;
|
|
47
|
-
const input = messages
|
|
48
|
-
.filter(m => m.role !== "system")
|
|
49
|
-
.map(m => ({
|
|
50
|
-
role: m.role,
|
|
51
|
-
content: [
|
|
52
|
-
{ type: m.role === "assistant" ? "output_text" : "input_text", text: m.content },
|
|
53
|
-
// Clipboard-pasted images ride along as input_image data URLs (user turns only —
|
|
54
|
-
// assistant history is always text in jeo).
|
|
55
|
-
...(m.role !== "assistant" && m.images?.length
|
|
56
|
-
? m.images.map(img => ({ type: "input_image", image_url: `data:${img.mediaType};base64,${img.data}` }))
|
|
57
|
-
: []),
|
|
58
|
-
],
|
|
59
|
-
}));
|
|
95
|
+
const input = buildResponsesInput(messages, options.model, stripArtifacts);
|
|
60
96
|
const payload: Record<string, unknown> = {
|
|
61
97
|
model,
|
|
62
98
|
instructions: systemPrompt ?? "You are a helpful coding assistant.",
|
|
@@ -81,6 +117,9 @@ export function codexResponsesRequest(
|
|
|
81
117
|
// Both speak the same Responses schema (the body above), so only url+headers differ.
|
|
82
118
|
if (credential.kind === "api_key") {
|
|
83
119
|
const base = (options.baseUrl ?? "https://api.openai.com/v1").replace(/\/$/, "");
|
|
120
|
+
// Stateless reasoning replay (public Responses API): ask for encrypted reasoning content
|
|
121
|
+
// so it can be captured and threaded back into a later `input` (store stays false).
|
|
122
|
+
payload.include = ["reasoning.encrypted_content"];
|
|
84
123
|
return {
|
|
85
124
|
url: `${base}/responses`,
|
|
86
125
|
headers: { "content-type": "application/json", authorization: `Bearer ${token}`, accept: "text/event-stream" },
|
|
@@ -113,6 +152,8 @@ export interface ResponsesEvent {
|
|
|
113
152
|
toolCallName?: string;
|
|
114
153
|
toolCallArgsDelta?: string;
|
|
115
154
|
toolCallIndex?: number;
|
|
155
|
+
/** A completed reasoning item carrying its id + encrypted_content (stateless replay capture). */
|
|
156
|
+
reasoningItem?: { id: string; encrypted: string };
|
|
116
157
|
}
|
|
117
158
|
|
|
118
159
|
/** Parse one Responses SSE `data:` payload into a delta / usage / error. */
|
|
@@ -120,7 +161,7 @@ export function parseResponsesEvent(data: string): ResponsesEvent {
|
|
|
120
161
|
let o: {
|
|
121
162
|
type?: string;
|
|
122
163
|
delta?: unknown;
|
|
123
|
-
item?: { type?: string; name?: string };
|
|
164
|
+
item?: { type?: string; name?: string; id?: string; encrypted_content?: string };
|
|
124
165
|
output_index?: number;
|
|
125
166
|
response?: {
|
|
126
167
|
usage?: { input_tokens?: number; output_tokens?: number };
|
|
@@ -137,6 +178,11 @@ export function parseResponsesEvent(data: string): ResponsesEvent {
|
|
|
137
178
|
if (o.type === "response.output_item.added" && o.item?.type === "function_call") {
|
|
138
179
|
return { toolCallName: o.item.name, toolCallIndex: o.output_index };
|
|
139
180
|
}
|
|
181
|
+
// A completed reasoning item carries the encrypted_content we replay later (needs the
|
|
182
|
+
// request's `include: ["reasoning.encrypted_content"]`). Captured on output_item.done.
|
|
183
|
+
if (o.type === "response.output_item.done" && o.item?.type === "reasoning" && o.item.id && o.item.encrypted_content) {
|
|
184
|
+
return { reasoningItem: { id: o.item.id, encrypted: o.item.encrypted_content } };
|
|
185
|
+
}
|
|
140
186
|
if (o.type === "response.function_call_arguments.delta" && typeof o.delta === "string") {
|
|
141
187
|
return { toolCallArgsDelta: o.delta, toolCallIndex: o.output_index };
|
|
142
188
|
}
|
|
@@ -185,10 +231,20 @@ function emptyCompletionError(reason: string | undefined): Error {
|
|
|
185
231
|
return new Error(`OpenAI Codex returned no content${reason ? ` (${reason})` : ""}${hint}.`);
|
|
186
232
|
}
|
|
187
233
|
|
|
234
|
+
/** Fetch the Responses endpoint with a reasoning-artifact fail-safe (see fetchWithArtifactFailSafe). */
|
|
235
|
+
function fetchResponses(messages: Message[], options: CallOptions, credential: Credential): Promise<Response> {
|
|
236
|
+
return fetchWithArtifactFailSafe(
|
|
237
|
+
strip => {
|
|
238
|
+
const { url, headers, body } = codexResponsesRequest(messages, options, credential, strip);
|
|
239
|
+
return fetch(url, { method: "POST", headers, body, signal: options.signal });
|
|
240
|
+
},
|
|
241
|
+
(status, body) => status === 400 && /reasoning|encrypted_content/i.test(body),
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
188
245
|
/** Non-streaming call over the Codex backend (collects the streamed output). */
|
|
189
246
|
export async function codexResponsesCall(messages: Message[], options: CallOptions, credential: Credential): Promise<string> {
|
|
190
|
-
const
|
|
191
|
-
const response = await fetch(url, { method: "POST", headers, body, signal: options.signal });
|
|
247
|
+
const response = await fetchResponses(messages, options, credential);
|
|
192
248
|
if (!response.ok) throw await providerHttpError("OpenAI", response);
|
|
193
249
|
if (!response.body) return "";
|
|
194
250
|
let out = "";
|
|
@@ -198,6 +254,7 @@ export async function codexResponsesCall(messages: Message[], options: CallOptio
|
|
|
198
254
|
const ev = parseResponsesEvent(data);
|
|
199
255
|
if (ev.delta) out += ev.delta;
|
|
200
256
|
if (ev.reasoningDelta) options.onReasoning?.(ev.reasoningDelta);
|
|
257
|
+
if (ev.reasoningItem) options.onReasoningArtifact?.({ provider: "openai", model: options.model, itemId: ev.reasoningItem.id, encrypted: ev.reasoningItem.encrypted });
|
|
201
258
|
accumulateResponsesToolCall(toolAcc, ev);
|
|
202
259
|
if (ev.usage) options.onUsage?.(ev.usage);
|
|
203
260
|
if (ev.incompleteReason) incompleteReason = ev.incompleteReason;
|
|
@@ -216,8 +273,7 @@ export async function* codexResponsesStream(
|
|
|
216
273
|
options: CallOptions,
|
|
217
274
|
credential: Credential,
|
|
218
275
|
): AsyncGenerator<string> {
|
|
219
|
-
const
|
|
220
|
-
const response = await fetch(url, { method: "POST", headers, body, signal: options.signal });
|
|
276
|
+
const response = await fetchResponses(messages, options, credential);
|
|
221
277
|
if (!response.ok) throw await providerHttpError("OpenAI", response, "(stream)");
|
|
222
278
|
if (!response.body) return;
|
|
223
279
|
let yieldedAny = false;
|
|
@@ -226,6 +282,7 @@ export async function* codexResponsesStream(
|
|
|
226
282
|
for await (const data of readSse(response.body)) {
|
|
227
283
|
const ev = parseResponsesEvent(data);
|
|
228
284
|
if (ev.reasoningDelta) options.onReasoning?.(ev.reasoningDelta);
|
|
285
|
+
if (ev.reasoningItem) options.onReasoningArtifact?.({ provider: "openai", model: options.model, itemId: ev.reasoningItem.id, encrypted: ev.reasoningItem.encrypted });
|
|
229
286
|
if (ev.delta) {
|
|
230
287
|
yieldedAny = true;
|
|
231
288
|
yield ev.delta;
|
package/src/ai/types.ts
CHANGED
|
@@ -19,9 +19,58 @@ export interface Message {
|
|
|
19
19
|
images?: ImageAttachment[];
|
|
20
20
|
/** Persisted reasoning/thinking text for an assistant turn (the thought before the
|
|
21
21
|
* answer). Survives /resume + export so the durable record shows "think → answer".
|
|
22
|
-
* Display
|
|
23
|
-
* the original signed block, which the streaming path does not capture). */
|
|
22
|
+
* Display channel; the REPLAY channel is `reasoningArtifacts`. */
|
|
24
23
|
reasoning?: string;
|
|
24
|
+
/** Provider-native, opaque reasoning artifacts captured during streaming (Anthropic
|
|
25
|
+
* thinking signature, Gemini thoughtSignature, OpenAI Responses reasoning items).
|
|
26
|
+
* Replayed to the SAME provider+model to preserve multi-step reasoning continuity;
|
|
27
|
+
* dropped on cross-model replay. Display-agnostic, not written to markdown export. */
|
|
28
|
+
reasoningArtifacts?: ReasoningArtifact[];
|
|
29
|
+
/** Structured native tool calls this assistant turn made (with stable ids). `content`
|
|
30
|
+
* keeps the canonical JSON envelope for display/compaction/fallback adapters; capable
|
|
31
|
+
* adapters replay these as native tool_use / functionCall / function_call blocks. */
|
|
32
|
+
toolUse?: ToolUseRecord[];
|
|
33
|
+
/** Structured native tool results for a tool-feedback user turn (ids match the prior
|
|
34
|
+
* assistant's `toolUse`). Capable adapters replay these as native tool_result /
|
|
35
|
+
* functionResponse / function_call_output blocks. */
|
|
36
|
+
toolResults?: ToolResultRecord[];
|
|
37
|
+
/** Non-tool trailing text on a tool-feedback user turn (e.g. post-turn hook
|
|
38
|
+
* diagnostics) — replayed as a trailing text block after the native tool results. */
|
|
39
|
+
toolResultExtra?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A provider-native opaque reasoning artifact. Only replayed when `provider` AND
|
|
43
|
+
* `model` match the active call (the adapter stamps the exact wire model id). */
|
|
44
|
+
export interface ReasoningArtifact {
|
|
45
|
+
provider: ProviderName;
|
|
46
|
+
model: string;
|
|
47
|
+
/** Thought text (display is covered by Message.reasoning; kept here for fidelity). */
|
|
48
|
+
text?: string;
|
|
49
|
+
/** Anthropic: thinking block signature. */
|
|
50
|
+
signature?: string;
|
|
51
|
+
/** Anthropic: redacted_thinking opaque data. */
|
|
52
|
+
redacted?: string;
|
|
53
|
+
/** Gemini: per-part thoughtSignature (binds to the matching functionCall part). */
|
|
54
|
+
thoughtSignature?: string;
|
|
55
|
+
/** OpenAI Responses: reasoning item id. */
|
|
56
|
+
itemId?: string;
|
|
57
|
+
/** OpenAI Responses: reasoning item encrypted_content. */
|
|
58
|
+
encrypted?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A structured native tool call (assistant turn). `id` is a stable synthetic id the
|
|
62
|
+
* engine assigns so tool_use ↔ tool_result correlation survives replay. */
|
|
63
|
+
export interface ToolUseRecord {
|
|
64
|
+
id: string;
|
|
65
|
+
tool: string;
|
|
66
|
+
arguments: Record<string, unknown>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A structured native tool result (user turn). `id` matches a prior `ToolUseRecord`. */
|
|
70
|
+
export interface ToolResultRecord {
|
|
71
|
+
id: string;
|
|
72
|
+
output: string;
|
|
73
|
+
isError: boolean;
|
|
25
74
|
}
|
|
26
75
|
|
|
27
76
|
export interface Usage {
|
|
@@ -67,6 +116,10 @@ export interface CallOptions {
|
|
|
67
116
|
* answer text). Surfaced as a transient dimmed view; absent for models that emit no
|
|
68
117
|
* thought text. */
|
|
69
118
|
onReasoning?: (delta: string) => void;
|
|
119
|
+
/** Sink for provider-native reasoning ARTIFACTS captured during streaming (signature /
|
|
120
|
+
* thoughtSignature / reasoning item id+encrypted). Separate from `onReasoning` (display
|
|
121
|
+
* text) because these arrive on different SSE events and are opaque replay data. */
|
|
122
|
+
onReasoningArtifact?: (artifact: ReasoningArtifact) => void;
|
|
70
123
|
/** NATIVE tool-calling: function declarations the model may call. Present only on the
|
|
71
124
|
* main agent step (never the prose wrap-up). Adapters with `supportsNativeTools` send
|
|
72
125
|
* these on the wire and re-serialize the structured tool call back into the engine's
|
package/src/commands/launch.ts
CHANGED
|
@@ -250,12 +250,25 @@ export function providerPickEntries(live: ProviderModelsResult[], want: Provider
|
|
|
250
250
|
if (catalog.length) {
|
|
251
251
|
return catalog.map((m, i) => ({ index: i + 1, provider: want, model: qualifyModelId(m.providerModel, want) }));
|
|
252
252
|
}
|
|
253
|
+
// Offline fallback for catalog-less (OpenAI-compatible) providers: the def's
|
|
254
|
+
// defaultModel first, then its knownModels list, so the per-role provider picker
|
|
255
|
+
// shows several pickable ids instead of one. De-duped + provider-qualified.
|
|
256
|
+
const def = openaiCompatDef(want);
|
|
257
|
+
if (def) {
|
|
258
|
+
const ids = [def.defaultModel, ...(def.knownModels ?? [])].map(m => qualifyModelId(m, want));
|
|
259
|
+
const seen = new Set<string>();
|
|
260
|
+
const entries: PickEntry[] = [];
|
|
261
|
+
for (const model of ids) {
|
|
262
|
+
if (seen.has(model)) continue;
|
|
263
|
+
seen.add(model);
|
|
264
|
+
entries.push({ index: entries.length + 1, provider: want, model });
|
|
265
|
+
}
|
|
266
|
+
if (entries.length) return entries;
|
|
267
|
+
}
|
|
253
268
|
const fallback = providerDefaultModel(want);
|
|
254
269
|
return fallback ? [{ index: 1, provider: want, model: qualifyModelId(fallback, want) }] : [];
|
|
255
270
|
}
|
|
256
271
|
|
|
257
|
-
|
|
258
|
-
|
|
259
272
|
export function formatResumeHint(sessionId: string): string {
|
|
260
273
|
return `Resume with: jeo launch --resume ${sessionId}`;
|
|
261
274
|
}
|
|
@@ -510,6 +523,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
510
523
|
// answer). Captured from the reasoning stream and persisted on the assistant message so
|
|
511
524
|
// it survives /resume + export (gjc "think → answer" record). Reset at each turn start.
|
|
512
525
|
let lastTurnReasoning = "";
|
|
526
|
+
// Native reasoning artifacts for the FINAL (done) step — the engine attaches intermediate
|
|
527
|
+
// steps' artifacts to their own pushed messages, but the done turn is built here. Reset on
|
|
528
|
+
// each step boundary so only the last step's artifacts ride the final reply (no duplication).
|
|
529
|
+
let lastTurnArtifacts: import("../ai/types").ReasoningArtifact[] = [];
|
|
513
530
|
/** Wrap turn events so EVERY sink (TUI or plain stream) records the last full
|
|
514
531
|
* tool output for the Ctrl+O detail view. */
|
|
515
532
|
const withToolDetailCapture = (base: ReturnType<LaunchTui["events"]>): ReturnType<LaunchTui["events"]> => ({
|
|
@@ -518,12 +535,22 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
518
535
|
lastToolDetail = { tool, output };
|
|
519
536
|
base.onToolResult?.(tool, success, output);
|
|
520
537
|
},
|
|
538
|
+
onStep: (step: number) => {
|
|
539
|
+
// New step: drop the prior step's final-reply artifacts so only the LAST step's ride
|
|
540
|
+
// the done reply (intermediate steps are persisted by the engine on their own turns).
|
|
541
|
+
lastTurnArtifacts = [];
|
|
542
|
+
base.onStep?.(step);
|
|
543
|
+
},
|
|
521
544
|
onReasoningStream: (textSoFar: string) => {
|
|
522
545
|
// textSoFar is the cumulative thought for the current step; keep the latest
|
|
523
546
|
// non-empty value (the thought immediately preceding the turn's answer).
|
|
524
547
|
if (textSoFar.trim()) lastTurnReasoning = textSoFar;
|
|
525
548
|
base.onReasoningStream?.(textSoFar);
|
|
526
549
|
},
|
|
550
|
+
onReasoningArtifactStream: (artifact) => {
|
|
551
|
+
lastTurnArtifacts.push(artifact);
|
|
552
|
+
base.onReasoningArtifactStream?.(artifact);
|
|
553
|
+
},
|
|
527
554
|
});
|
|
528
555
|
/** Compose a session-persistence flush into onStep so each completed step is
|
|
529
556
|
* written as it lands (durability across mid-turn interruption) without
|
|
@@ -626,6 +653,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
626
653
|
// persistence block below.
|
|
627
654
|
let beforeLen = history.length;
|
|
628
655
|
lastTurnReasoning = ""; // fresh turn: capture this turn's thinking from scratch
|
|
656
|
+
lastTurnArtifacts = [];
|
|
629
657
|
// Incremental session persistence (durability across mid-turn interruption):
|
|
630
658
|
// persistTurnTail() flushes history messages added since the last flush — called
|
|
631
659
|
// right after the user prompt, on every onStep boundary, and once post-turn — so
|
|
@@ -929,9 +957,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
929
957
|
// this only covers the tail — net content is the full turn either way.
|
|
930
958
|
try {
|
|
931
959
|
await persistTurnTail();
|
|
932
|
-
const assistantMsg: Message =
|
|
933
|
-
|
|
934
|
-
|
|
960
|
+
const assistantMsg: Message = { role: "assistant", content: reply };
|
|
961
|
+
if (lastTurnReasoning.trim()) assistantMsg.reasoning = lastTurnReasoning;
|
|
962
|
+
if (lastTurnArtifacts.length) assistantMsg.reasoningArtifacts = lastTurnArtifacts;
|
|
935
963
|
history.push(assistantMsg);
|
|
936
964
|
if (sessionId) await appendMessage(sessionId, assistantMsg, cwd);
|
|
937
965
|
if (tui) tui.finish(reply);
|
|
@@ -1616,7 +1644,15 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1616
1644
|
jeoEnv("NO_SLASH_PREVIEW") !== "1";
|
|
1617
1645
|
// Footer height reserved by the CURRENTLY armed region; disarm/draw must use the
|
|
1618
1646
|
// same value the arm computed, even if the terminal was resized in between.
|
|
1647
|
+
// `footerRows` is the MAX reservation height (the budget previewLines/historyPreview
|
|
1648
|
+
// may fill). The PHYSICAL reservation (`footerRendered`) is now dynamic: compact at
|
|
1649
|
+
// idle (no dropdown) so a finished/idle prompt leaves NO reserved blank rows, and
|
|
1650
|
+
// grown on demand when a slash/arg preview needs more. `footerWantRows` is the height
|
|
1651
|
+
// the latest previewLines/historyPreview wants; drawFooter re-pins to it in place.
|
|
1619
1652
|
let footerRows = MAX_PREVIEW_ROWS;
|
|
1653
|
+
// Compact idle reservation: status bar (1) + spacer (1) + input box (3 rows).
|
|
1654
|
+
const COMPACT_FOOTER_ROWS = 5;
|
|
1655
|
+
let footerWantRows = COMPACT_FOOTER_ROWS;
|
|
1620
1656
|
const out = process.stdout;
|
|
1621
1657
|
// Arrow-key selection over the slash preview list.
|
|
1622
1658
|
let navMatches: string[] = []; // command names matching the typed keyword (display order)
|
|
@@ -1666,24 +1702,42 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1666
1702
|
// line painted at an older, wider geometry reflows onto extra rows after a width shrink.
|
|
1667
1703
|
let lastDrawnLines: string[] = [];
|
|
1668
1704
|
const padToFooter = (lines: string[]): string[] => {
|
|
1669
|
-
if (lines.length >=
|
|
1670
|
-
return [...lines, ...new Array(
|
|
1705
|
+
if (lines.length >= footerRendered) return lines.slice(0, footerRendered);
|
|
1706
|
+
return [...lines, ...new Array(footerRendered - lines.length).fill("")];
|
|
1671
1707
|
};
|
|
1672
1708
|
const armPreview = () => {
|
|
1673
1709
|
if (!previewEnabled || previewArmed) return;
|
|
1674
1710
|
footerRows = previewRowsFor(process.stdout.rows ?? 24);
|
|
1675
|
-
// Reserve
|
|
1676
|
-
//
|
|
1677
|
-
//
|
|
1678
|
-
|
|
1679
|
-
|
|
1711
|
+
// Reserve a COMPACT region (idle prompt height) right after the current output —
|
|
1712
|
+
// not the full `footerRows` budget — so a finished/idle prompt leaves no blank rows
|
|
1713
|
+
// below it. drawFooter grows the reservation in place when a dropdown needs more.
|
|
1714
|
+
const initial = Math.max(1, Math.min(footerRows, COMPACT_FOOTER_ROWS));
|
|
1715
|
+
if (initial > 1) {
|
|
1716
|
+
out.write("\n".repeat(initial - 1) + cursorUp(initial - 1));
|
|
1680
1717
|
}
|
|
1681
1718
|
out.write(toColumn(1));
|
|
1682
|
-
footerRendered =
|
|
1719
|
+
footerRendered = initial;
|
|
1720
|
+
footerWantRows = initial;
|
|
1683
1721
|
footerParkedRow = 0;
|
|
1684
1722
|
previewArmed = true;
|
|
1685
1723
|
lastFooterKey = "";
|
|
1686
1724
|
};
|
|
1725
|
+
// Re-pin the reservation to `n` rows IN PLACE (right after the existing output, never
|
|
1726
|
+
// bottom-pinned): clear the old region from its top, then reserve `n` rows there. Used
|
|
1727
|
+
// by drawFooter to grow for a dropdown and shrink back to the compact idle height, so
|
|
1728
|
+
// the prompt never carries a trailing/floating blank block.
|
|
1729
|
+
const setFooterRows = (n: number) => {
|
|
1730
|
+
n = Math.max(1, Math.min(n, footerRows));
|
|
1731
|
+
if (!previewArmed || n === footerRendered) return;
|
|
1732
|
+
let s = footerParkedRow > 0 ? cursorUp(footerParkedRow) : "";
|
|
1733
|
+
s += toColumn(1) + clearToEnd(); // wipe old region; cursor now at its top (after output)
|
|
1734
|
+
if (n > 1) s += "\n".repeat(n - 1) + cursorUp(n - 1);
|
|
1735
|
+
s += toColumn(1);
|
|
1736
|
+
out.write(s);
|
|
1737
|
+
footerRendered = n;
|
|
1738
|
+
footerParkedRow = 0;
|
|
1739
|
+
lastFooterKey = ""; // force a full repaint into the resized region
|
|
1740
|
+
};
|
|
1687
1741
|
// Clear the reserved region and park the cursor at its top row so subsequent
|
|
1688
1742
|
// command output starts where the box was (and inherits the existing scrollback).
|
|
1689
1743
|
const disarmPreview = () => {
|
|
@@ -1800,7 +1854,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1800
1854
|
const slash = budget > 0 ? formatSlashPreview(line, budget, selected, skillSlashDetails, resolvedSkills) : [];
|
|
1801
1855
|
const args = !slash.length && budget > 0 ? formatCompletionPreview(line, completionContext(), budget) : [];
|
|
1802
1856
|
const preview = (slash.length ? slash : args).map(l => chalk.gray(truncateAnsi(l, cols)));
|
|
1803
|
-
|
|
1857
|
+
const result = [statusBarLine(cols), "", ...input, ...preview].slice(0, footerRows);
|
|
1858
|
+
// Want only the input box + status bar at idle (no dropdown) → compact reservation;
|
|
1859
|
+
// grow to fit the dropdown when a preview is present.
|
|
1860
|
+
footerWantRows = preview.length > 0 ? result.length : Math.min(footerRows, 2 + input.length);
|
|
1861
|
+
return result;
|
|
1804
1862
|
};
|
|
1805
1863
|
// Render the reversible Ctrl+O detail panel into the footer reservation: a status
|
|
1806
1864
|
// bar, a title (with scroll hint when needed), then a windowed slice of the detail
|
|
@@ -1838,10 +1896,15 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1838
1896
|
if (below > 0) body.push(chalk.dim(`↓ ${below} more below`));
|
|
1839
1897
|
}
|
|
1840
1898
|
footerCursor = { row: Math.min(1, footerRows - 1), col: 1 };
|
|
1841
|
-
|
|
1899
|
+
const result = [statusBarLine(cols), title, ...body].slice(0, footerRows);
|
|
1900
|
+
footerWantRows = result.length; // the Ctrl+O panel sizes the reservation to its content
|
|
1901
|
+
return result;
|
|
1842
1902
|
};
|
|
1843
1903
|
const drawFooter = (lines: string[]) => {
|
|
1844
1904
|
if (!previewArmed || footerRendered === 0) return;
|
|
1905
|
+
// Re-pin the reservation to the height the latest preview/panel wants (compact at
|
|
1906
|
+
// idle, grown for a dropdown) BEFORE painting, so no reserved blank trails the prompt.
|
|
1907
|
+
setFooterRows(footerWantRows);
|
|
1845
1908
|
// ALWAYS paint exactly footerRendered rows so the reservation is fully covered
|
|
1846
1909
|
// and no row can spill past it — the bug fix that kept `@folder<more text>`
|
|
1847
1910
|
// typing from scrolling the input box (and prior output) off the top.
|
|
@@ -2558,9 +2621,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2558
2621
|
out.write(clearScreen());
|
|
2559
2622
|
out.write(renderWelcome({ ...welcomeData, cols }).join("\n") + "\n");
|
|
2560
2623
|
footerRows = previewRowsFor(rows);
|
|
2561
|
-
|
|
2624
|
+
const initial = Math.max(1, Math.min(footerRows, COMPACT_FOOTER_ROWS));
|
|
2625
|
+
if (initial > 1) out.write("\n".repeat(initial - 1) + cursorUp(initial - 1));
|
|
2562
2626
|
out.write(toColumn(1));
|
|
2563
|
-
footerRendered =
|
|
2627
|
+
footerRendered = initial;
|
|
2628
|
+
footerWantRows = initial;
|
|
2564
2629
|
drawFooter(promptHistoryLines ? historyPreviewLines(promptHistoryLines) : previewLines(typedLine, navIdx));
|
|
2565
2630
|
return;
|
|
2566
2631
|
}
|
|
@@ -2591,14 +2656,17 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2591
2656
|
const caretSubRow = Math.floor(Math.max(0, footerCursor.col - 1) / c);
|
|
2592
2657
|
const hopUp = abovePhysical + caretSubRow;
|
|
2593
2658
|
let s = (hopUp > 0 ? cursorUp(hopUp) : "") + toColumn(1) + clearToEnd();
|
|
2594
|
-
// Re-pin a
|
|
2595
|
-
//
|
|
2659
|
+
// Re-pin a COMPACT reservation right where the frame top was (just below the
|
|
2660
|
+
// static content) — NOT bottom-pinned. ED already blanked from the frame top
|
|
2661
|
+
// down, so reserve the idle height here; drawFooter grows it for a dropdown.
|
|
2662
|
+
// Bottom-pinning left a tall blank gap above the bar when the content was short.
|
|
2596
2663
|
footerRows = previewRowsFor(rows);
|
|
2597
|
-
|
|
2598
|
-
if (
|
|
2664
|
+
const initial = Math.max(1, Math.min(footerRows, COMPACT_FOOTER_ROWS));
|
|
2665
|
+
if (initial > 1) s += "\n".repeat(initial - 1) + cursorUp(initial - 1);
|
|
2599
2666
|
s += toColumn(1);
|
|
2600
2667
|
out.write(s);
|
|
2601
|
-
footerRendered =
|
|
2668
|
+
footerRendered = initial;
|
|
2669
|
+
footerWantRows = initial;
|
|
2602
2670
|
footerParkedRow = 0;
|
|
2603
2671
|
lastFooterKey = "";
|
|
2604
2672
|
drawFooter(promptHistoryLines ? historyPreviewLines(promptHistoryLines) : previewLines(typedLine, navIdx));
|
package/src/tui/app.ts
CHANGED
|
@@ -68,6 +68,9 @@ export interface AgentEventsLike {
|
|
|
68
68
|
onUsage?(usage: { inputTokens: number; outputTokens: number }): void;
|
|
69
69
|
onModelStream?(textSoFar: string): void;
|
|
70
70
|
onReasoningStream?(textSoFar: string): void;
|
|
71
|
+
/** Per-artifact native reasoning replay records (signature / thoughtSignature / reasoning
|
|
72
|
+
* item). The TUI ignores these; launch.ts uses them to persist the final reply's artifacts. */
|
|
73
|
+
onReasoningArtifactStream?(artifact: import("../ai/types").ReasoningArtifact): void;
|
|
71
74
|
onBudget?(limit: number, reason: string): void;
|
|
72
75
|
|
|
73
76
|
}
|
|
@@ -112,6 +115,27 @@ export function tailForWrap(text: string, maxChars = FRAME_WRAP_TAIL_CHARS): str
|
|
|
112
115
|
return text.length > maxChars ? text.slice(text.length - maxChars) : text;
|
|
113
116
|
}
|
|
114
117
|
|
|
118
|
+
/** Max lines of a committed reasoning block kept in scrollback (gjc-style collapse): a
|
|
119
|
+
* long chain-of-thought is clipped with a "+N more" hint so it never floods the ledger. */
|
|
120
|
+
export const THINKING_COMMIT_MAX_LINES = 12;
|
|
121
|
+
|
|
122
|
+
/** Collapse a committed reasoning block to a line cap, appending a "… (+N more lines)"
|
|
123
|
+
* hint when clipped (gjc collapsed-by-default parity). Returns the input verbatim when
|
|
124
|
+
* it already fits. */
|
|
125
|
+
export function clipReasoningLines(text: string, cap = THINKING_COMMIT_MAX_LINES): string {
|
|
126
|
+
const rows = text.replace(/\r/g, "").split("\n");
|
|
127
|
+
if (rows.length <= cap) return rows.join("\n");
|
|
128
|
+
return [...rows.slice(0, cap), `… (+${rows.length - cap} more lines)`].join("\n");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** gjc-style "thought for Ns" header for a committed/streaming Thinking block. Omits the
|
|
132
|
+
* duration when no step start is known (e.g. resumed/exported records). */
|
|
133
|
+
export function thinkingHeader(elapsedMs: number | undefined, unicode: boolean): string {
|
|
134
|
+
const diamond = unicode ? "◇" : "*";
|
|
135
|
+
const secs = elapsedMs !== undefined && elapsedMs >= 0 ? `${(elapsedMs / 1000).toFixed(1)}s` : null;
|
|
136
|
+
return `${diamond} thinking${secs ? ` · ${secs}` : ""}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
115
139
|
/** Status animation palette while a tool/process runs (background verification): an
|
|
116
140
|
* amber→yellow gradient, distinct from the cool thinking gradient, so "the agent is
|
|
117
141
|
* running a process / verifying" reads at a glance (gjc parity: `theme.fg("warning")`
|
|
@@ -444,13 +468,17 @@ export class LaunchTui {
|
|
|
444
468
|
: (s: string) => s;
|
|
445
469
|
const style = (prose: string) => prose.split("\n").map(styleThought).join("\n");
|
|
446
470
|
const parts: string[] = [this.agentLabel()];
|
|
471
|
+
// gjc "thought for Ns" header: step-start → commit ≈ the model's think+gen time.
|
|
472
|
+
const elapsedMs = this.currentStepStartedAt ? Date.now() - this.currentStepStartedAt : undefined;
|
|
473
|
+
const header = thinkingHeader(elapsedMs, this.unicode);
|
|
474
|
+
parts.push(this.theme.color ? chalk.dim(header) : header);
|
|
447
475
|
if (willFlushThought) {
|
|
448
476
|
this.flushedThought = this.streamingThought;
|
|
449
|
-
parts.push(style(this.streamingThought));
|
|
477
|
+
parts.push(style(clipReasoningLines(this.streamingThought)));
|
|
450
478
|
}
|
|
451
479
|
if (willFlushReasoning) {
|
|
452
480
|
this.flushedReasoning = this.streamingReasoning;
|
|
453
|
-
parts.push(style(this.streamingReasoning));
|
|
481
|
+
parts.push(style(clipReasoningLines(this.streamingReasoning)));
|
|
454
482
|
}
|
|
455
483
|
this.appendLedger(`${parts.join("\n")}\n`, "reasoning");
|
|
456
484
|
}
|
|
@@ -1206,7 +1234,7 @@ export class LaunchTui {
|
|
|
1206
1234
|
* block shows only the most-recent lines, capped at ~30% of the screen height (a
|
|
1207
1235
|
* ceiling guards a tall terminal), so it grows with the stream and shrinks with the
|
|
1208
1236
|
* viewport. Returns [] when there is nothing to show. */
|
|
1209
|
-
private renderLiveBlock(label: string, text: string, cols: number, rows: number, ceiling: number): string[] {
|
|
1237
|
+
private renderLiveBlock(label: string, text: string, cols: number, rows: number, ceiling: number, cacheKey = label): string[] {
|
|
1210
1238
|
const dim = this.theme.color ? chalk.dim : (s: string) => s;
|
|
1211
1239
|
if (!text.trim()) return [];
|
|
1212
1240
|
const wrapW = Math.max(8, cols - 2);
|
|
@@ -1214,8 +1242,8 @@ export class LaunchTui {
|
|
|
1214
1242
|
// this (up to 16KB) tail every frame just re-segments graphemes for no visible change.
|
|
1215
1243
|
// Per-label slot (Thinking / Output) keyed by wrap width + text — a real delta misses
|
|
1216
1244
|
// once and recomputes; an idle tick hits the cache. `rows` only gates the post-slice.
|
|
1217
|
-
let cache = this.liveBlockWrapCaches.get(
|
|
1218
|
-
if (!cache) { cache = lastValueCache<string[]>(); this.liveBlockWrapCaches.set(
|
|
1245
|
+
let cache = this.liveBlockWrapCaches.get(cacheKey);
|
|
1246
|
+
if (!cache) { cache = lastValueCache<string[]>(); this.liveBlockWrapCaches.set(cacheKey, cache); }
|
|
1219
1247
|
const wrapped = cache(`${wrapW}\u0000${text}`, () =>
|
|
1220
1248
|
tailForWrap(text)
|
|
1221
1249
|
.split("\n")
|
|
@@ -1349,7 +1377,11 @@ export class LaunchTui {
|
|
|
1349
1377
|
// rectangle, so a short trace leaves no padded "hole" and a short terminal is spared.
|
|
1350
1378
|
const liveThink = this.streamingThought.trim() || this.streamingReasoning.trim();
|
|
1351
1379
|
if (isThinking && liveThink) {
|
|
1352
|
-
|
|
1380
|
+
// gjc-parity: the Thinking block label carries a running timer ("Thinking · Ns").
|
|
1381
|
+
// Cache key stays the constant "Thinking" so the per-frame wrap memo is unaffected.
|
|
1382
|
+
const liveMs = this.currentStepStartedAt ? Date.now() - this.currentStepStartedAt : undefined;
|
|
1383
|
+
const liveLabel = liveMs !== undefined ? `Thinking · ${(liveMs / 1000).toFixed(1)}s` : "Thinking";
|
|
1384
|
+
tail.push(...this.renderLiveBlock(liveLabel, liveThink, cols, rows, 6, "Thinking"));
|
|
1353
1385
|
}
|
|
1354
1386
|
|
|
1355
1387
|
// Live tool output (gjc-style streaming bash stdout): while a tool runs, its
|