@visual-z/xeno 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +13 -2
- package/docs/providers.md +1 -1
- package/package.json +1 -1
- package/src/apps/provider-connect.ts +3 -3
- package/src/apps/session-hub.ts +12 -1
- package/src/apps/shell.ts +1 -1
- package/src/apps/ui/app.ts +81 -19
- package/src/apps/ui/image-cache.ts +71 -28
- package/src/apps/update-check.ts +21 -10
- package/src/core/activity-status.ts +28 -0
- package/src/core/git-status.ts +29 -0
- package/src/core/image-limits.ts +72 -0
- package/src/core/progress.ts +9 -0
- package/src/core/request-budget.ts +13 -0
- package/src/core/session-exit.ts +26 -0
- package/src/core/settings.ts +1 -1
- package/src/core/stall.ts +18 -6
- package/src/core/stop-report.ts +25 -0
- package/src/runtime/exec-sessions.ts +40 -4
- package/src/runtime/images.ts +6 -2
- package/src/runtime/loop.ts +56 -11
- package/src/runtime/oauth.ts +19 -1
- package/src/runtime/openai-responses.ts +6 -4
- package/src/runtime/openai.ts +6 -1
- package/src/runtime/pi-provider.ts +11 -5
- package/src/runtime/provider-presets.ts +11 -0
- package/src/runtime/provider-request.ts +3 -1
- package/src/runtime/provider-tool-policy.ts +33 -0
- package/src/runtime/provider.ts +6 -1
- package/src/runtime/session-control.ts +1 -0
- package/src/runtime/tools.ts +7 -3
- package/src/store/blobs.ts +2 -1
- package/src/store/git-status.ts +15 -0
- package/src/tui/ui/dialog.ts +24 -5
- package/src/tui/ui/layout.ts +7 -7
- package/src/tui/ui/messages.ts +1 -1
- package/src/tui/ui/pictures.ts +26 -9
- package/src/tui/ui/screen.ts +30 -9
- package/src/tui/ui/status.ts +32 -6
|
@@ -24,6 +24,7 @@ import { assertNever } from "../core/exhaustive.ts";
|
|
|
24
24
|
import type { OnDelta, Provider, ProviderConfig, ToolSchema, Turn } from "./provider.ts";
|
|
25
25
|
import { pushSse, HttpError, StreamError } from "./openai.ts";
|
|
26
26
|
import { withRetry, DEFAULT_RETRY, type RetryPolicy } from "./retry.ts";
|
|
27
|
+
import type { ProviderSendOptions } from "./provider-tool-policy.ts";
|
|
27
28
|
|
|
28
29
|
/** What this adapter parks in a thinking block's raw: the reasoning item, whole. */
|
|
29
30
|
interface ResponsesRaw { responses_item: Record<string, unknown> }
|
|
@@ -266,19 +267,20 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
266
267
|
this.onRetry = options.onRetry;
|
|
267
268
|
}
|
|
268
269
|
|
|
269
|
-
send(frozen: FrozenPrefix, messages: readonly Message[], node: number, signal?: AbortSignal, onDelta?: OnDelta): Promise<Turn> {
|
|
270
|
-
return withRetry(() => this.attempt(frozen, messages, node, signal, onDelta), this.retry, undefined,
|
|
270
|
+
send(frozen: FrozenPrefix, messages: readonly Message[], node: number, signal?: AbortSignal, onDelta?: OnDelta, options?: ProviderSendOptions): Promise<Turn> {
|
|
271
|
+
return withRetry(() => this.attempt(frozen, messages, node, signal, onDelta, options), this.retry, undefined,
|
|
271
272
|
(attempt, delayMs, error) => { onDelta?.({ kind: "discard" }); onDelta?.({ kind: "retry", attempt, delayMs }); this.onRetry?.(attempt, delayMs, error); }, signal);
|
|
272
273
|
}
|
|
273
274
|
|
|
274
|
-
private async attempt(frozen: FrozenPrefix, messages: readonly Message[], node: number, signal?: AbortSignal, onDelta?: OnDelta): Promise<Turn> {
|
|
275
|
+
private async attempt(frozen: FrozenPrefix, messages: readonly Message[], node: number, signal?: AbortSignal, onDelta?: OnDelta, options?: ProviderSendOptions): Promise<Turn> {
|
|
275
276
|
const started = Date.now();
|
|
276
277
|
const baseUrl = this.config.spec.baseUrl;
|
|
277
278
|
if (baseUrl === undefined) throw new Error(`model ${this.config.spec.id} has no baseUrl to send to`);
|
|
278
279
|
const init: RequestInit = {
|
|
279
280
|
method: "POST",
|
|
280
281
|
headers: { "content-type": "application/json", authorization: `Bearer ${this.apiKey}` },
|
|
281
|
-
body: JSON.stringify({ ...buildResponsesRequest(this.config, frozen, messages), stream: true
|
|
282
|
+
body: JSON.stringify({ ...buildResponsesRequest(this.config, frozen, messages), stream: true,
|
|
283
|
+
...(options?.toolUse === "disabled" ? { tool_choice: "none" } : {}) }),
|
|
282
284
|
};
|
|
283
285
|
if (signal !== undefined) init.signal = signal;
|
|
284
286
|
const response = await this.fetchImpl(`${baseUrl.replace(/\/$/, "")}/responses`, init);
|
package/src/runtime/openai.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { assertNever } from "../core/exhaustive.ts";
|
|
|
26
26
|
import type { Provider, ProviderConfig, Turn, ToolSchema, OnDelta } from "./provider.ts";
|
|
27
27
|
import { DEFAULT_COMPAT, type Compat, type Effort, type ModelSpec } from "./model.ts";
|
|
28
28
|
import { withRetry, DEFAULT_RETRY, type RetryPolicy } from "./retry.ts";
|
|
29
|
+
import type { ProviderSendOptions } from "./provider-tool-policy.ts";
|
|
29
30
|
|
|
30
31
|
/** One message as Chat Completions wants it. */
|
|
31
32
|
export interface WireMessage {
|
|
@@ -48,6 +49,7 @@ export interface WireRequest {
|
|
|
48
49
|
max_tokens?: number;
|
|
49
50
|
max_completion_tokens?: number;
|
|
50
51
|
tools?: unknown[];
|
|
52
|
+
tool_choice?: "none";
|
|
51
53
|
thinking?: { type: "enabled" | "disabled" };
|
|
52
54
|
reasoning_effort?: string;
|
|
53
55
|
}
|
|
@@ -434,11 +436,12 @@ export class OpenAICompletionsProvider implements Provider {
|
|
|
434
436
|
node: number,
|
|
435
437
|
signal?: AbortSignal,
|
|
436
438
|
onDelta?: OnDelta,
|
|
439
|
+
options?: ProviderSendOptions,
|
|
437
440
|
): Promise<Turn> {
|
|
438
441
|
// The clock starts inside the retried request, so a reported duration is
|
|
439
442
|
// one attempt rather than the sum of the ones that failed.
|
|
440
443
|
return withRetry(
|
|
441
|
-
() => this.attempt(frozen, messages, node, signal, onDelta),
|
|
444
|
+
() => this.attempt(frozen, messages, node, signal, onDelta, options),
|
|
442
445
|
this.retry,
|
|
443
446
|
undefined,
|
|
444
447
|
(attempt, delayMs, error) => {
|
|
@@ -456,6 +459,7 @@ export class OpenAICompletionsProvider implements Provider {
|
|
|
456
459
|
node: number,
|
|
457
460
|
signal?: AbortSignal,
|
|
458
461
|
onDelta?: OnDelta,
|
|
462
|
+
options?: ProviderSendOptions,
|
|
459
463
|
): Promise<Turn> {
|
|
460
464
|
const started = Date.now();
|
|
461
465
|
const baseUrl = this.config.spec.baseUrl;
|
|
@@ -468,6 +472,7 @@ export class OpenAICompletionsProvider implements Provider {
|
|
|
468
472
|
stream: true;
|
|
469
473
|
stream_options?: { include_usage: true };
|
|
470
474
|
} = { ...buildRequest(this.config, frozen, messages), stream: true };
|
|
475
|
+
if (options?.toolUse === "disabled") payload.tool_choice = "none";
|
|
471
476
|
// Without include_usage an OpenAI-dialect stream never reports usage, and
|
|
472
477
|
// the loop would treat the resulting zeros as a real prompt size. Servers
|
|
473
478
|
// that 400 on the field opt out via Compat.supportsUsageInStreaming.
|
|
@@ -17,12 +17,14 @@ import type { AssistantBlock } from "../core/events.ts";
|
|
|
17
17
|
import { toolCallIdentityViolations, type FrozenPrefix, type Message } from "../core/project.ts";
|
|
18
18
|
import type { Api, ModelSpec } from "./model.ts";
|
|
19
19
|
import { providerRequest } from "./provider-request.ts";
|
|
20
|
+
import { grokVersionHint } from "./oauth.ts";
|
|
20
21
|
import type { ResolveEffects } from "./auth.ts";
|
|
21
22
|
import { imageData } from "./images.ts";
|
|
22
23
|
import { FailedResponseError, type OnDelta, type Provider, type ProviderConfig, type ToolSchema, type Turn } from "./provider.ts";
|
|
23
24
|
import { DEFAULT_RETRY, withRetry, type RetryPolicy } from "./retry.ts";
|
|
24
25
|
import { HttpError, StreamError } from "./openai.ts";
|
|
25
26
|
import type { UsageSample } from "./usage.ts";
|
|
27
|
+
import { disablePayloadTools, type ProviderSendOptions } from "./provider-tool-policy.ts";
|
|
26
28
|
|
|
27
29
|
const APIS: Record<Api, ProviderStreams> = {
|
|
28
30
|
"anthropic-messages": anthropicMessagesApi(), "openai-completions": openAICompletionsApi(),
|
|
@@ -305,14 +307,14 @@ export class PiProvider implements Provider {
|
|
|
305
307
|
this.config = config; this.apiKey = apiKey; this.options = options;
|
|
306
308
|
}
|
|
307
309
|
|
|
308
|
-
send(frozen: FrozenPrefix, messages: readonly Message[], node: number, signal?: AbortSignal, onDelta?: OnDelta): Promise<Turn> {
|
|
310
|
+
send(frozen: FrozenPrefix, messages: readonly Message[], node: number, signal?: AbortSignal, onDelta?: OnDelta, policy?: ProviderSendOptions): Promise<Turn> {
|
|
309
311
|
// Bedrock's Pi adapter leaves retry ownership with the AWS SDK; never multiply that loop.
|
|
310
312
|
const retry = this.options.retry ?? (this.config.spec.api === "bedrock-converse-stream" ? { ...DEFAULT_RETRY, maxRetries: 0 } : DEFAULT_RETRY);
|
|
311
|
-
return withRetry(() => this.attempt(frozen, messages, node, signal, onDelta), retry, undefined,
|
|
313
|
+
return withRetry(() => this.attempt(frozen, messages, node, signal, onDelta, policy), retry, undefined,
|
|
312
314
|
(attempt, delayMs, error) => { onDelta?.({ kind: "discard" }); onDelta?.({ kind: "retry", attempt, delayMs }); this.options.onRetry?.(attempt, delayMs, error); }, signal);
|
|
313
315
|
}
|
|
314
316
|
|
|
315
|
-
private async attempt(frozen: FrozenPrefix, messages: readonly Message[], node: number, signal?: AbortSignal, onDelta?: OnDelta): Promise<Turn> {
|
|
317
|
+
private async attempt(frozen: FrozenPrefix, messages: readonly Message[], node: number, signal?: AbortSignal, onDelta?: OnDelta, policy?: ProviderSendOptions): Promise<Turn> {
|
|
316
318
|
signal?.throwIfAborted();
|
|
317
319
|
const started = Date.now();
|
|
318
320
|
const request = providerRequest(this.config.spec, this.apiKey, this.options.effects, this.options.fetch, this.options.oauth);
|
|
@@ -342,7 +344,7 @@ export class PiProvider implements Provider {
|
|
|
342
344
|
const next = mergePayload(payload, request.body);
|
|
343
345
|
// Stateless Responses is required by D8: never replace the recomputable projection with server state.
|
|
344
346
|
if (model.api.includes("responses")) { next["store"] = false; delete next["previous_response_id"]; }
|
|
345
|
-
return next;
|
|
347
|
+
return policy?.toolUse === "disabled" ? disablePayloadTools(model.api, next) : next;
|
|
346
348
|
} };
|
|
347
349
|
let completed: AssistantMessage | undefined;
|
|
348
350
|
// Pi emits fragments; xeno's progress contract replaces the displayed argument buffer.
|
|
@@ -372,7 +374,11 @@ export class PiProvider implements Provider {
|
|
|
372
374
|
case "error":
|
|
373
375
|
if (event.reason === "aborted" || signal?.aborted) throw new DOMException("Request aborted", "AbortError");
|
|
374
376
|
status ??= nativeStatus(event.error.errorMessage ?? "");
|
|
375
|
-
if (status && status >= 400)
|
|
377
|
+
if (status && status >= 400) {
|
|
378
|
+
// The proxy's own text says "run `grok update`", which a xeno user cannot act on.
|
|
379
|
+
const hint = grokVersionHint(status, this.config.spec.provider);
|
|
380
|
+
throw new HttpError(status, responseHeaders, (event.error.errorMessage ?? "Provider request failed") + (hint ? `\n ${hint}` : ""));
|
|
381
|
+
}
|
|
376
382
|
throw new StreamError(event.error.errorMessage ?? "Provider stream failed");
|
|
377
383
|
case "start": case "text_start": case "text_end": case "thinking_start": case "thinking_end": break;
|
|
378
384
|
default: assertNever(event, "pi stream event");
|
|
@@ -74,6 +74,17 @@ export function validProviderId(id: string): boolean {
|
|
|
74
74
|
return /^[a-z0-9][a-z0-9_-]{0,63}$/.test(id);
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* The valid id a rejected one most likely meant ("My Gateway" → "my-gateway"),
|
|
79
|
+
* or "" when nothing Latin survives ("网关"). Only offered in the error, never
|
|
80
|
+
* applied silently: the id becomes a config and auth.json key the user will
|
|
81
|
+
* type in /model, so they should see it before it is saved.
|
|
82
|
+
*/
|
|
83
|
+
export function slugProviderId(raw: string): string {
|
|
84
|
+
if (validProviderId(raw)) return raw;
|
|
85
|
+
return raw.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^[^a-z0-9]+/, "").slice(0, 64).replace(/-+$/, "");
|
|
86
|
+
}
|
|
87
|
+
|
|
77
88
|
/** A readable default id from a URL host: api.example.com → example. */
|
|
78
89
|
export function suggestProviderId(baseUrl: string, taken: ReadonlySet<string>): string {
|
|
79
90
|
let host = "custom";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { providerEnvironment, resolveKeyValue, type ResolveEffects } from "./auth.ts";
|
|
2
2
|
import { providerAccess } from "./provider-access.ts";
|
|
3
3
|
import type { ModelSpec } from "./model.ts";
|
|
4
|
-
import { XENO_VERSION, GROK_PROVIDER, GROK_BASE_URL } from "./oauth.ts";
|
|
4
|
+
import { XENO_VERSION, GROK_PROVIDER, GROK_BASE_URL, GROK_PROTOCOL_VERSION } from "./oauth.ts";
|
|
5
5
|
|
|
6
6
|
const SDK_IDENTITY_HEADERS = ["x-app", "editor-version", "editor-plugin-version", "copilot-integration-id", "http-referer", "x-title"];
|
|
7
7
|
|
|
@@ -45,6 +45,8 @@ export function providerRequest(spec: ModelSpec, key: string, effects: ResolveEf
|
|
|
45
45
|
// These select the auth protocol and backend; the User-Agent still identifies xeno (D18).
|
|
46
46
|
headers["X-XAI-Token-Auth"] = "xai-grok-cli";
|
|
47
47
|
headers["x-grok-model-override"] = spec.wireId ?? spec.id;
|
|
48
|
+
headers["x-grok-client-version"] = GROK_PROTOCOL_VERSION;
|
|
49
|
+
headers["x-grok-client-identifier"] = "xeno";
|
|
48
50
|
}
|
|
49
51
|
const anthropicOAuth = spec.api === "anthropic-messages" && spec.provider !== "github-copilot" && (oauth || key.includes("sk-ant-oat"));
|
|
50
52
|
const noAutomaticAuth = access.auth === false || access.authHeader === false || typeof access.authHeader === "string" || anthropicOAuth;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** D6: native request controls may change; frozen system and tool declarations may not. */
|
|
2
|
+
import { assertNever } from "../core/exhaustive.ts";
|
|
3
|
+
import type { Api } from "./model.ts";
|
|
4
|
+
|
|
5
|
+
export interface ProviderSendOptions { toolUse: "disabled" }
|
|
6
|
+
|
|
7
|
+
export function supportsToolDisabling(api: Api): boolean {
|
|
8
|
+
// Bedrock's SDK implements none by deleting toolConfig. It cannot preserve D6.
|
|
9
|
+
switch (api) {
|
|
10
|
+
case "anthropic-messages": case "openai-completions": case "openai-responses": case "azure-openai-responses": case "openai-codex-responses":
|
|
11
|
+
case "google-generative-ai": case "google-vertex": case "mistral-conversations": case "pi-messages": return true;
|
|
12
|
+
case "bedrock-converse-stream": return false;
|
|
13
|
+
default: return assertNever(api, "tool disabling capability");
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const object = (value: unknown): Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
17
|
+
|
|
18
|
+
/** Called after provider body overrides, at the last native payload boundary. */
|
|
19
|
+
export function disablePayloadTools(api: Api, payload: Record<string, unknown>): Record<string, unknown> {
|
|
20
|
+
switch (api) {
|
|
21
|
+
case "anthropic-messages": return { ...payload, tool_choice: { type: "none" } };
|
|
22
|
+
case "openai-completions": case "openai-responses": case "azure-openai-responses": case "openai-codex-responses":
|
|
23
|
+
return { ...payload, tool_choice: "none" };
|
|
24
|
+
case "google-generative-ai": case "google-vertex": {
|
|
25
|
+
const config = object(payload["config"]), toolConfig = object(config["toolConfig"]);
|
|
26
|
+
return { ...payload, config: { ...config, toolConfig: { ...toolConfig, functionCallingConfig: { mode: "NONE" } } } };
|
|
27
|
+
}
|
|
28
|
+
case "mistral-conversations": return { ...payload, toolChoice: "none" };
|
|
29
|
+
case "pi-messages": return { ...payload, options: { ...object(payload["options"]), toolChoice: "none" } };
|
|
30
|
+
case "bedrock-converse-stream": throw new Error("Bedrock cannot disable tools while preserving frozen definitions");
|
|
31
|
+
default: return assertNever(api, "tool disabling protocol");
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/runtime/provider.ts
CHANGED
|
@@ -39,6 +39,7 @@ import type { UsageSample } from "./usage.ts";
|
|
|
39
39
|
import { type Effort, type ModelSpec, CLAUDE_OPUS_5 } from "./model.ts";
|
|
40
40
|
import { withRetry, DEFAULT_RETRY, type RetryPolicy } from "./retry.ts";
|
|
41
41
|
import { anthropicOAuthHeaders } from "./oauth.ts";
|
|
42
|
+
import type { ProviderSendOptions } from "./provider-tool-policy.ts";
|
|
42
43
|
|
|
43
44
|
export interface ToolSchema {
|
|
44
45
|
name: string;
|
|
@@ -106,6 +107,7 @@ export interface Provider {
|
|
|
106
107
|
node: number,
|
|
107
108
|
signal?: AbortSignal,
|
|
108
109
|
onDelta?: OnDelta,
|
|
110
|
+
options?: ProviderSendOptions,
|
|
109
111
|
): Promise<Turn>;
|
|
110
112
|
}
|
|
111
113
|
|
|
@@ -235,9 +237,10 @@ export class AnthropicProvider implements Provider {
|
|
|
235
237
|
node: number,
|
|
236
238
|
signal?: AbortSignal,
|
|
237
239
|
onDelta?: OnDelta,
|
|
240
|
+
options?: ProviderSendOptions,
|
|
238
241
|
): Promise<Turn> {
|
|
239
242
|
return withRetry(
|
|
240
|
-
() => this.attempt(frozen, messages, node, signal, onDelta),
|
|
243
|
+
() => this.attempt(frozen, messages, node, signal, onDelta, options),
|
|
241
244
|
this.retry,
|
|
242
245
|
undefined,
|
|
243
246
|
(attempt, delayMs, error) => {
|
|
@@ -257,6 +260,7 @@ export class AnthropicProvider implements Provider {
|
|
|
257
260
|
node: number,
|
|
258
261
|
signal?: AbortSignal,
|
|
259
262
|
onDelta?: OnDelta,
|
|
263
|
+
options?: ProviderSendOptions,
|
|
260
264
|
): Promise<Turn> {
|
|
261
265
|
const started = Date.now();
|
|
262
266
|
|
|
@@ -277,6 +281,7 @@ export class AnthropicProvider implements Provider {
|
|
|
277
281
|
// Auto-caching walks the breakpoint forward over the growing tail.
|
|
278
282
|
cache_control: { type: "ephemeral" },
|
|
279
283
|
tools: frozen.tools as Anthropic.Tool[],
|
|
284
|
+
...(options?.toolUse === "disabled" ? { tool_choice: { type: "none" as const } } : {}),
|
|
280
285
|
// Only a reasoning model understands these. An older Claude, or an
|
|
281
286
|
// Anthropic-compatible endpoint serving some other model, answers
|
|
282
287
|
// them with a 400 or silently ignores them.
|
|
@@ -164,6 +164,7 @@ export class SessionControl {
|
|
|
164
164
|
// Assumed ingress bound, not an execution policy. Keep a client from queuing unbounded work.
|
|
165
165
|
if (turn.queue.length >= 32) throw Error("steering queue is full");
|
|
166
166
|
turn.queue.push(structuredClone(input));
|
|
167
|
+
this.session.wakeProcessWaits();
|
|
167
168
|
this.emit("turn/steeringQueued", { count: turn.queue.length });
|
|
168
169
|
}
|
|
169
170
|
interrupt(turnId: string): void {
|
package/src/runtime/tools.ts
CHANGED
|
@@ -127,6 +127,9 @@ export interface ToolContext {
|
|
|
127
127
|
historyCache?: HistoryCache;
|
|
128
128
|
/** O.14: opaque process ownership is supplied by Session, never by model input. */
|
|
129
129
|
execOwner?: string;
|
|
130
|
+
/** Session-owned empty stdin waits; low-level/native callers retain bounded polling. */
|
|
131
|
+
processWait?: { wake(): AbortSignal };
|
|
132
|
+
onProcessWait?: (session: number, active: boolean) => void;
|
|
130
133
|
/** Session-owned registrations never mutate the built-in table (D13). */
|
|
131
134
|
extensions?: ReadonlyMap<string, ToolDefinition>;
|
|
132
135
|
/** R11: current locally selected optional schemas, never restored as authority from historical text. */
|
|
@@ -629,7 +632,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
629
632
|
access: "shell", execution: "exclusive", branch: "write", run: toolWriteStdin,
|
|
630
633
|
description:
|
|
631
634
|
"Send characters to an owned running process handle and return the output since the last call. " +
|
|
632
|
-
"
|
|
635
|
+
"In an agent session, empty chars waits for new output, exit, hard timeout, cancellation or user steering without periodic model polling. Include \\n to submit a line; \\u0003 is Ctrl-C, \\u0004 is Ctrl-D (end of input). " +
|
|
633
636
|
"After exit, empty chars retrieves the last receipt without rerunning or producing fresh verification. Source and byte cursor are in data; full_output recovers all captured output. " +
|
|
634
637
|
"Lost handles cannot accept input; a last running observation is not an exit. Ctrl-C may interrupt a PTY foreground job while its REPL stays alive.",
|
|
635
638
|
input_schema: {
|
|
@@ -637,7 +640,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
637
640
|
properties: {
|
|
638
641
|
session_id: { type: "integer", minimum: 1 },
|
|
639
642
|
chars: { type: "string", description: "Characters to write; empty to poll" },
|
|
640
|
-
yield_time_ms: { type: "integer", minimum: 100, maximum: 30000, description: "
|
|
643
|
+
yield_time_ms: { type: "integer", minimum: 100, maximum: 30000, description: "Bounded wait when sending input or using low-level polling; agent empty waits are owned until an observation changes" },
|
|
641
644
|
},
|
|
642
645
|
required: ["session_id"],
|
|
643
646
|
},
|
|
@@ -1108,7 +1111,8 @@ async function toolWriteStdin(ctx: ToolContext, input: Record<string, unknown>):
|
|
|
1108
1111
|
data: { kind: "process", version: 1, session_id: id, state: "unknown", capability: "unknown", source: null, running: null, exit: null, replayed: true } };
|
|
1109
1112
|
}
|
|
1110
1113
|
const outputStore = ctx.outputStore ?? join(ctx.root, ".xeno", "observations");
|
|
1111
|
-
return sessionResult(ctx, session.command, session.timeoutMs, await pollSession(session, outputStore, ctx.maxOutputBytes, clampYield(input["yield_time_ms"]), chars, ctx.signal
|
|
1114
|
+
return sessionResult(ctx, session.command, session.timeoutMs, await pollSession(session, outputStore, ctx.maxOutputBytes, clampYield(input["yield_time_ms"]), chars, ctx.signal,
|
|
1115
|
+
ctx.processWait && chars.length === 0 ? { wake: ctx.processWait.wake(), onWaiting: active => ctx.onProcessWait?.(id, active) } : undefined));
|
|
1112
1116
|
}
|
|
1113
1117
|
|
|
1114
1118
|
/** A successful historical query proves only that the receipt was found, never a new command exit. */
|
package/src/store/blobs.ts
CHANGED
|
@@ -121,8 +121,9 @@ export async function getBlob(dir: string, sha: string): Promise<string | null>
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
/** Restore uses bytes; decoding binary data is an irreversible transformation. */
|
|
124
|
-
export async function getBlobBytes(dir: string, sha: string): Promise<Uint8Array | null> {
|
|
124
|
+
export async function getBlobBytes(dir: string, sha: string, maxBytes?: number): Promise<Uint8Array | null> {
|
|
125
125
|
try {
|
|
126
|
+
if (maxBytes !== undefined && (await stat(blobFilePath(dir, sha))).size > maxBytes) throw new RangeError("Blob exceeds byte limit");
|
|
126
127
|
const bytes = await readFile(blobFilePath(dir, sha));
|
|
127
128
|
if (blobAddress(bytes) !== sha) throw new Error(`blob integrity failure: ${sha}`);
|
|
128
129
|
return bytes;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Local Git reads are asynchronous and bounded; a paint never launches a process. */
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { parseGitStatus, type GitStatus } from "../core/git-status.ts";
|
|
4
|
+
/** Assumed cold-disk allowance; Git failure remains unavailable rather than "clean". */
|
|
5
|
+
const GIT_STATUS_TIMEOUT_MS = 1000;
|
|
6
|
+
/** A busy repository cannot flood memory or the status view with paths. */
|
|
7
|
+
const GIT_STATUS_MAX_BYTES = 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
export function readGitStatus(cwd: string): Promise<GitStatus | null> {
|
|
10
|
+
return new Promise(resolve => {
|
|
11
|
+
execFile("git", ["--no-optional-locks", "status", "--porcelain=v2", "--branch", "-z", "--untracked-files=normal"],
|
|
12
|
+
{ cwd, timeout: GIT_STATUS_TIMEOUT_MS, maxBuffer: GIT_STATUS_MAX_BYTES, encoding: "utf8" },
|
|
13
|
+
(error, output) => resolve(error ? null : parseGitStatus(output)));
|
|
14
|
+
});
|
|
15
|
+
}
|
package/src/tui/ui/dialog.ts
CHANGED
|
@@ -40,9 +40,20 @@ export type DialogSpec =
|
|
|
40
40
|
| { kind: "busy"; title: string; message: string; onCancel?(): void }
|
|
41
41
|
| { kind: "lines"; title: string; render(width: number): string[]; handleKey?(key: KeyPress): boolean | Promise<boolean>; hint?: string; onCancel?(): void };
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* One stacked dialog. `list` and `row` (a plain form's label + Input) are
|
|
45
|
+
* persistent: built once, they carry typed text and cursor across repaints,
|
|
46
|
+
* and `show()` only detaches them. Everything else a repaint builds is
|
|
47
|
+
* ephemeral and destroyed recursively on the next one, so a persistent node
|
|
48
|
+
* must never sit inside an ephemeral parent -- that destroyed the form's
|
|
49
|
+
* Input on the first validation error and every later key threw
|
|
50
|
+
* (docs/tasks/fix-dialog-form-and-grok-version.md).
|
|
51
|
+
*/
|
|
43
52
|
interface Open {
|
|
44
53
|
spec: DialogSpec;
|
|
45
54
|
list?: ListView;
|
|
55
|
+
row?: BoxRenderable;
|
|
56
|
+
label?: TextRenderable;
|
|
46
57
|
input?: InputRenderable;
|
|
47
58
|
secret?: string;
|
|
48
59
|
error?: string | undefined;
|
|
@@ -112,6 +123,7 @@ export class DialogHost {
|
|
|
112
123
|
for (const open of this.stack) {
|
|
113
124
|
open.list?.setColors(colors);
|
|
114
125
|
if (open.input) { open.input.textColor = colors.text; open.input.focusedTextColor = colors.text; open.input.placeholderColor = colors.muted; }
|
|
126
|
+
if (open.label && open.spec.kind === "form") open.label.content = this.formLabel(open.spec.label);
|
|
115
127
|
}
|
|
116
128
|
if (this.open) this.show();
|
|
117
129
|
}
|
|
@@ -180,6 +192,10 @@ export class DialogHost {
|
|
|
180
192
|
focusedTextColor: this.colors.text, backgroundColor: "transparent", focusedBackgroundColor: "transparent", flexGrow: 1 });
|
|
181
193
|
// Prefilled values are edited from their end (a URL gets a path appended).
|
|
182
194
|
open.input.gotoBufferEnd();
|
|
195
|
+
open.row = new BoxRenderable(this.renderer, { id: `form-row-${serial++}`, flexDirection: "row", height: 1 });
|
|
196
|
+
open.label = new TextRenderable(this.renderer, { id: `form-label-${serial++}`, content: this.formLabel(spec.label), flexShrink: 0 });
|
|
197
|
+
open.row.add(open.label);
|
|
198
|
+
open.row.add(open.input);
|
|
183
199
|
}
|
|
184
200
|
if (spec.kind === "form" && spec.secret) open.secret = "";
|
|
185
201
|
if (spec.kind === "confirm") open.yes = true;
|
|
@@ -228,17 +244,18 @@ export class DialogHost {
|
|
|
228
244
|
}
|
|
229
245
|
case "form": {
|
|
230
246
|
if (spec.intro?.length) this.add(text(ansiText(spec.intro), { marginBottom: 1 }));
|
|
231
|
-
|
|
232
|
-
row.add(new TextRenderable(this.renderer, { id: `form-label-${serial++}`, content: parts({ text: `${spec.label} › `, fg: c.muted }), flexShrink: 0 }));
|
|
233
|
-
if (top.input) { row.add(top.input); top.input.focus(); }
|
|
247
|
+
if (top.row) { this.slot.add(top.row); top.input!.focus(); }
|
|
234
248
|
else {
|
|
235
|
-
// The secret never reaches a renderable: only its length does.
|
|
249
|
+
// The secret never reaches a renderable: only its length does. The
|
|
250
|
+
// whole row is rebuilt per repaint, so it holds nothing persistent.
|
|
251
|
+
const row = new BoxRenderable(this.renderer, { id: `form-row-${serial++}`, flexDirection: "row", height: 1 });
|
|
252
|
+
row.add(new TextRenderable(this.renderer, { id: `form-label-${serial++}`, content: this.formLabel(spec.label), flexShrink: 0 }));
|
|
236
253
|
const mask = canvas([[{ text: "•".repeat(Math.min(top.secret!.length, 48)), role: "text" }, { text: "▏", role: "accent" }]]);
|
|
237
254
|
mask.flexGrow = 1;
|
|
238
255
|
row.add(mask);
|
|
256
|
+
this.add(row);
|
|
239
257
|
this.renderer.currentFocusedRenderable?.blur();
|
|
240
258
|
}
|
|
241
|
-
this.add(row);
|
|
242
259
|
if (top.error) this.add(text(parts({ text: top.error, fg: c.error })));
|
|
243
260
|
break;
|
|
244
261
|
}
|
|
@@ -272,6 +289,8 @@ export class DialogHost {
|
|
|
272
289
|
this.renderer.requestRender();
|
|
273
290
|
}
|
|
274
291
|
|
|
292
|
+
private formLabel(label: string): ReturnType<typeof parts> { return parts({ text: `${label} › `, fg: this.colors.muted }); }
|
|
293
|
+
|
|
275
294
|
private paintChrome(): void {
|
|
276
295
|
const top = this.stack.at(-1);
|
|
277
296
|
const spec = top?.spec;
|
package/src/tui/ui/layout.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Where status lives at a given terminal size (docs/tasks/refactor-tui-dock-layout.md). Pure.
|
|
3
3
|
*
|
|
4
|
-
* sidebar ≥ 110 cols and ≥ 20 rows right column
|
|
4
|
+
* sidebar ≥ 110 cols and ≥ 20 rows right column plus compact provider header
|
|
5
5
|
* topbar 80–109 cols, or a wide but short terminal (≥ 18 rows) one row on top
|
|
6
6
|
* bare everything else the input box's bottom edge only;
|
|
7
7
|
* session and branch go to the terminal title
|
|
@@ -42,16 +42,16 @@ export function screenLayout(cols: number, rows: number, sidebarWanted = true):
|
|
|
42
42
|
const c = Math.max(0, Math.floor(cols)), r = Math.max(0, Math.floor(rows));
|
|
43
43
|
if (sidebarWanted && c >= SIDEBAR_MIN_COLUMNS && r >= SIDEBAR_MIN_ROWS) {
|
|
44
44
|
const sidebarWidth = sidebarContentWidth(c) + 1;
|
|
45
|
-
return { tier: "sidebar", sidebarWidth, mainWidth: c - sidebarWidth, topbar:
|
|
45
|
+
return { tier: "sidebar", sidebarWidth, mainWidth: c - sidebarWidth, topbar: true };
|
|
46
46
|
}
|
|
47
47
|
if (c >= TOPBAR_MIN_COLUMNS && r >= TOPBAR_MIN_ROWS) return { tier: "topbar", sidebarWidth: 0, mainWidth: c, topbar: true };
|
|
48
48
|
return { tier: "bare", sidebarWidth: 0, mainWidth: c, topbar: false };
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
export const STATUS_FIELDS = ["model", "thinking", "context", "title", "workspace", "branch", "usage", "cost", "handoff", "tasks", "files", "background", "resources", "extension"] as const;
|
|
51
|
+
export const STATUS_FIELDS = ["mode", "provider", "activity", "git", "model", "thinking", "context", "title", "workspace", "branch", "usage", "cost", "handoff", "tasks", "files", "background", "resources", "extension"] as const;
|
|
52
52
|
export type StatusField = (typeof STATUS_FIELDS)[number];
|
|
53
53
|
/** Screen surfaces only; the terminal title is outside the frame and listed separately. */
|
|
54
|
-
export type Place = "sidebar" | "topbar" | "bottomEdge" | "hidden";
|
|
54
|
+
export type Place = "sidebar" | "topbar" | "topEdge" | "bottomEdge" | "hidden";
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
57
|
* The one place each field takes in a tier. Hidden fields stay one command
|
|
@@ -61,13 +61,13 @@ export type Place = "sidebar" | "topbar" | "bottomEdge" | "hidden";
|
|
|
61
61
|
export function placement(tier: Tier): Readonly<Record<StatusField, Place>> {
|
|
62
62
|
switch (tier) {
|
|
63
63
|
case "sidebar":
|
|
64
|
-
return { model: "bottomEdge", thinking: "bottomEdge", context: "sidebar", title: "sidebar", workspace: "sidebar", branch: "sidebar",
|
|
64
|
+
return { mode: "bottomEdge", provider: "topbar", activity: "topEdge", git: "sidebar", model: "bottomEdge", thinking: "bottomEdge", context: "sidebar", title: "sidebar", workspace: "sidebar", branch: "sidebar",
|
|
65
65
|
usage: "sidebar", cost: "sidebar", handoff: "sidebar", tasks: "sidebar", files: "sidebar", background: "sidebar", resources: "sidebar", extension: "sidebar" };
|
|
66
66
|
case "topbar":
|
|
67
|
-
return { model: "bottomEdge", thinking: "bottomEdge", context: "bottomEdge", title: "topbar", workspace: "topbar", branch: "topbar",
|
|
67
|
+
return { mode: "bottomEdge", provider: "topbar", activity: "topEdge", git: "hidden", model: "bottomEdge", thinking: "bottomEdge", context: "bottomEdge", title: "topbar", workspace: "topbar", branch: "topbar",
|
|
68
68
|
usage: "hidden", cost: "topbar", handoff: "topbar", tasks: "hidden", files: "hidden", background: "topbar", resources: "hidden", extension: "topbar" };
|
|
69
69
|
case "bare":
|
|
70
|
-
return { model: "bottomEdge", thinking: "bottomEdge", context: "bottomEdge", title: "hidden", workspace: "hidden", branch: "hidden",
|
|
70
|
+
return { mode: "bottomEdge", provider: "hidden", activity: "topEdge", git: "hidden", model: "bottomEdge", thinking: "bottomEdge", context: "bottomEdge", title: "hidden", workspace: "hidden", branch: "hidden",
|
|
71
71
|
usage: "hidden", cost: "hidden", handoff: "hidden", tasks: "hidden", files: "hidden", background: "hidden", resources: "hidden", extension: "hidden" };
|
|
72
72
|
default: return assertNever(tier, "placement");
|
|
73
73
|
}
|
package/src/tui/ui/messages.ts
CHANGED
|
@@ -34,7 +34,7 @@ export interface MessageFeatures {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
/** Without a source (tests, print mode) pictures are named, not drawn. */
|
|
37
|
-
const NO_PICTURES: PictureSource = { mode: "chip", protocol: "blocks", cell: DEFAULT_CELL, widthCells: 60, load: async () =>
|
|
37
|
+
const NO_PICTURES: PictureSource = { mode: "chip", protocol: "blocks", cell: DEFAULT_CELL, widthCells: 60, load: async () => ({ kind: "error", reason: "closed" }) };
|
|
38
38
|
|
|
39
39
|
interface Entry { rev: string; node: Renderable; item: ViewItem }
|
|
40
40
|
|
package/src/tui/ui/pictures.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { BoxRenderable, ImageRenderable, type CliRenderer, type ImageRenderProtocol, type NativeImage } from "@opentui/core";
|
|
14
14
|
import { Canvas } from "./canvas.ts";
|
|
15
15
|
import type { UiColors } from "./colors.ts";
|
|
16
|
-
import type
|
|
16
|
+
import { splitLine, type Seg } from "./edge.ts";
|
|
17
17
|
import { imageCells, IMAGES_PER_ITEM } from "./image-size.ts";
|
|
18
18
|
import type { ImageView } from "./items.ts";
|
|
19
19
|
|
|
@@ -25,10 +25,14 @@ export interface PictureSource {
|
|
|
25
25
|
readonly cell: { readonly width: number; readonly height: number };
|
|
26
26
|
/** The widest a picture may be, in cells (`terminal.imageWidthCells`). */
|
|
27
27
|
readonly widthCells: number;
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
readonly reason?: string;
|
|
29
|
+
/** The decoded picture for a blob, shared by every node that shows it, or its preparation failure. */
|
|
30
|
+
load(sha256: string): Promise<PictureLoad>;
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
export type PictureLoad = { readonly kind: "ready"; readonly image: NativeImage } |
|
|
34
|
+
{ readonly kind: "error"; readonly reason: "missing" | "corrupt" | "decode" | "limit" | "busy" | "closed" };
|
|
35
|
+
|
|
32
36
|
let serial = 0;
|
|
33
37
|
|
|
34
38
|
export function chipLine(images: readonly ImageView[], limit: number): Seg[] {
|
|
@@ -52,17 +56,31 @@ export class Pictures extends BoxRenderable {
|
|
|
52
56
|
const limit = options.limit ?? IMAGES_PER_ITEM;
|
|
53
57
|
const shown = images.slice(0, limit);
|
|
54
58
|
const pictures: ImageRenderable[] = [];
|
|
59
|
+
const states = shown.map(() => source.mode === "picture" ? "preparing" : source.reason ?? "preview unavailable");
|
|
60
|
+
const caption = new Canvas(ctx, { id: `${id}-caption`, height: Math.max(1, shown.length), flexShrink: 0, colors });
|
|
61
|
+
// A long filename must not hide the preparation state or the reason a preview failed.
|
|
62
|
+
const paintCaption = () => caption.setRows(shown.length ? shown.map((image, i) => splitLine(options.maxCols, [
|
|
63
|
+
{ segs: [{ text: "▣ ", role: "muted" }, { text: image.name, role: "text" }], priority: 0, min: 4, shrinkPriority: 5 },
|
|
64
|
+
{ segs: [{ text: `${image.width}×${image.height}`, role: "muted" }], priority: 3 },
|
|
65
|
+
...(i === shown.length - 1 && images.length > shown.length ? [{ segs: [{ text: `+${images.length - shown.length} more`, role: "muted" as const }], priority: 4 }] : []),
|
|
66
|
+
], [{ segs: [{ text: states[i]!, role: states[i]?.startsWith("preview failed") ? "warning" : "muted" }], priority: 0 }], 1, [{ text: " · ", role: "muted" }])) : [chipLine(images, limit)]);
|
|
55
67
|
if (source.mode === "picture" && shown.length) {
|
|
56
68
|
const row = new BoxRenderable(ctx, { id: `${id}-row`, flexDirection: "row", flexShrink: 0, columnGap: 1 });
|
|
57
69
|
// Pictures share the width; each keeps its own proportions.
|
|
58
70
|
const each = Math.max(1, Math.floor((Math.min(options.maxCols, source.widthCells * shown.length) - (shown.length - 1)) / shown.length));
|
|
59
|
-
for (const image of shown) {
|
|
71
|
+
for (const [index, image] of shown.entries()) {
|
|
60
72
|
const size = imageCells(image, Math.min(each, source.widthCells), options.maxRows, source.cell);
|
|
61
73
|
const picture = new ImageRenderable(ctx, { id: `${id}-${image.sha256.slice(0, 8)}-${serial++}`, width: size.cols, height: size.rows,
|
|
62
|
-
fit: "fit", protocol: source.protocol, flexShrink: 0
|
|
63
|
-
|
|
74
|
+
fit: "fit", protocol: source.protocol, flexShrink: 0,
|
|
75
|
+
onLoad: () => { states[index] = "decoded"; if (!caption.isDestroyed) paintCaption(); },
|
|
76
|
+
onError: () => { states[index] = "preview failed: decode"; picture.visible = false; if (!caption.isDestroyed) paintCaption(); } });
|
|
77
|
+
void source.load(image.sha256).then(result => {
|
|
78
|
+
if (picture.isDestroyed) return;
|
|
79
|
+
if (result.kind === "ready") picture.source = result.image;
|
|
80
|
+
else { picture.visible = false; states[index] = `preview failed: ${result.reason}`; paintCaption(); }
|
|
81
|
+
}).catch(() => {
|
|
64
82
|
if (picture.isDestroyed) return;
|
|
65
|
-
|
|
83
|
+
picture.visible = false; states[index] = "preview failed: decode"; paintCaption();
|
|
66
84
|
});
|
|
67
85
|
pictures.push(picture);
|
|
68
86
|
row.add(picture);
|
|
@@ -71,8 +89,7 @@ export class Pictures extends BoxRenderable {
|
|
|
71
89
|
}
|
|
72
90
|
this.pictures = pictures;
|
|
73
91
|
// The names stay visible beside the pictures (and stand in for them in chip mode).
|
|
74
|
-
|
|
75
|
-
caption.setRows([chipLine(images, source.mode === "picture" ? shown.length : limit)]);
|
|
92
|
+
paintCaption();
|
|
76
93
|
this.add(caption);
|
|
77
94
|
}
|
|
78
95
|
}
|