agent-sh 0.15.7 → 0.15.9
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/dist/agent/agent-loop.d.ts +3 -0
- package/dist/agent/agent-loop.js +17 -1
- package/dist/agent/events.d.ts +3 -0
- package/dist/agent/host-types.d.ts +6 -0
- package/dist/agent/index.js +5 -1
- package/dist/agent/llm-client.d.ts +2 -0
- package/dist/agent/llm-client.js +2 -2
- package/dist/agent/providers/openrouter.js +11 -1
- package/dist/shell/input-handler.js +5 -3
- package/dist/shell/strategies/bash.js +10 -2
- package/dist/shell/terminal.d.ts +2 -11
- package/dist/shell/terminal.js +37 -19
- package/dist/shell/tui-renderer.js +1 -1
- package/dist/utils/ansi.d.ts +7 -0
- package/dist/utils/ansi.js +20 -0
- package/dist/utils/floating-panel.js +5 -4
- package/dist/utils/line-editor.js +7 -4
- package/examples/extensions/ashi/package.json +1 -1
- package/examples/extensions/ashi/src/chat/assistant.ts +3 -1
- package/examples/extensions/ashi/src/cli.ts +8 -0
- package/examples/extensions/ashi/src/frontend.ts +4 -3
- package/examples/extensions/ashi/src/renderers/pi-tui/inline-image.ts +145 -0
- package/examples/extensions/ashi/src/renderers/pi-tui/nodes.ts +51 -1
- package/examples/extensions/ashi/src/user-shell-intents.ts +4 -1
- package/examples/extensions/latex-images.ts +152 -7
- package/package.json +1 -1
- package/src/agent/agent-loop.ts +17 -1
- package/src/agent/events.ts +1 -0
- package/src/agent/host-types.ts +2 -0
- package/src/agent/index.ts +7 -1
- package/src/agent/llm-client.ts +4 -2
- package/src/agent/providers/openrouter.ts +10 -1
- package/src/shell/input-handler.ts +5 -3
- package/src/shell/strategies/bash.ts +10 -2
- package/src/shell/terminal.ts +30 -19
- package/src/shell/tui-renderer.ts +1 -1
- package/src/utils/ansi.ts +21 -0
- package/src/utils/floating-panel.ts +5 -4
- package/src/utils/line-editor.ts +7 -4
|
@@ -84,6 +84,9 @@ export declare class AgentLoop implements AgentBackend {
|
|
|
84
84
|
kill(): void;
|
|
85
85
|
private cancel;
|
|
86
86
|
private reasoningParams;
|
|
87
|
+
/** Resume-stable conversation id from the frontend (e.g. ashi); undefined
|
|
88
|
+
* when the frontend tracks no session. */
|
|
89
|
+
private currentSessionId;
|
|
87
90
|
private resolveEndpoint;
|
|
88
91
|
private pullModels;
|
|
89
92
|
private emitIdentity;
|
package/dist/agent/agent-loop.js
CHANGED
|
@@ -390,6 +390,17 @@ export class AgentLoop {
|
|
|
390
390
|
const effort = this.thinkingLevel === "xhigh" ? "high" : this.thinkingLevel;
|
|
391
391
|
return { reasoning_effort: effort };
|
|
392
392
|
}
|
|
393
|
+
/** Resume-stable conversation id from the frontend (e.g. ashi); undefined
|
|
394
|
+
* when the frontend tracks no session. */
|
|
395
|
+
currentSessionId() {
|
|
396
|
+
try {
|
|
397
|
+
const id = this.handlers.call("session:current-id");
|
|
398
|
+
return typeof id === "string" && id ? id : undefined;
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
return undefined;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
393
404
|
resolveEndpoint(m) {
|
|
394
405
|
try {
|
|
395
406
|
return this.handlers.call("agent:resolve-endpoint", { provider: m.provider, id: m.id });
|
|
@@ -1251,7 +1262,12 @@ export class AgentLoop {
|
|
|
1251
1262
|
...this.reasoningParams(),
|
|
1252
1263
|
};
|
|
1253
1264
|
this.bus.emit("llm:request", requestParams);
|
|
1254
|
-
const
|
|
1265
|
+
const headers = this.activeEndpoint?.buildRequestHeaders?.({ sessionId: this.currentSessionId() });
|
|
1266
|
+
const stream = await this.llmClient.stream({
|
|
1267
|
+
...requestParams,
|
|
1268
|
+
signal,
|
|
1269
|
+
...(headers && Object.keys(headers).length ? { headers } : {}),
|
|
1270
|
+
});
|
|
1255
1271
|
try {
|
|
1256
1272
|
for await (const chunk of stream) {
|
|
1257
1273
|
if (signal.aborted)
|
package/dist/agent/events.d.ts
CHANGED
|
@@ -21,6 +21,9 @@ declare module "../core/event-bus.js" {
|
|
|
21
21
|
id: string;
|
|
22
22
|
reasoningParams?: (level: string, model?: string) => Record<string, unknown>;
|
|
23
23
|
cacheTokens?: (usage: Record<string, unknown>) => number | undefined;
|
|
24
|
+
requestHeaders?: (info: {
|
|
25
|
+
sessionId?: string;
|
|
26
|
+
}) => Record<string, string>;
|
|
24
27
|
};
|
|
25
28
|
"agent:models-changed": Record<string, never>;
|
|
26
29
|
"config:switch-provider": {
|
|
@@ -85,6 +85,9 @@ export interface ModelEndpoint {
|
|
|
85
85
|
baseURL?: string;
|
|
86
86
|
buildReasoningParams?: (level: string) => Record<string, unknown>;
|
|
87
87
|
extractCachedTokens?: (usage: Record<string, unknown>) => number | undefined;
|
|
88
|
+
buildRequestHeaders?: (info: {
|
|
89
|
+
sessionId?: string;
|
|
90
|
+
}) => Record<string, string>;
|
|
88
91
|
}
|
|
89
92
|
/**
|
|
90
93
|
* Capabilities the agent host adds on top of CoreContext. Only available
|
|
@@ -100,6 +103,9 @@ export interface AgentSurface {
|
|
|
100
103
|
configure: (id: string, opts: {
|
|
101
104
|
reasoningParams?: (level: string, model?: string) => Record<string, unknown>;
|
|
102
105
|
cacheTokens?: (usage: Record<string, unknown>) => number | undefined;
|
|
106
|
+
requestHeaders?: (info: {
|
|
107
|
+
sessionId?: string;
|
|
108
|
+
}) => Record<string, string>;
|
|
103
109
|
}) => void;
|
|
104
110
|
};
|
|
105
111
|
registerTool: (tool: ToolDefinition) => void;
|
package/dist/agent/index.js
CHANGED
|
@@ -105,6 +105,7 @@ export default function agentBackend(ctx) {
|
|
|
105
105
|
return hook ? (level) => hook(level, model) : defaultReasoningBuilder;
|
|
106
106
|
};
|
|
107
107
|
const bindCacheTokens = (shapeId) => providerHooks.get(shapeId)?.cacheTokens ?? defaultCacheTokens;
|
|
108
|
+
const bindRequestHeaders = (shapeId) => providerHooks.get(shapeId)?.requestHeaders;
|
|
108
109
|
const agentSurface = {
|
|
109
110
|
llm: createLlmFacade({ list: ctx.list, call: ctx.call }),
|
|
110
111
|
providers: {
|
|
@@ -322,6 +323,7 @@ export default function agentBackend(ctx) {
|
|
|
322
323
|
baseURL: p.baseURL,
|
|
323
324
|
buildReasoningParams: bindReasoning(shapeId, modelId),
|
|
324
325
|
extractCachedTokens: bindCacheTokens(shapeId),
|
|
326
|
+
buildRequestHeaders: bindRequestHeaders(shapeId),
|
|
325
327
|
};
|
|
326
328
|
};
|
|
327
329
|
ctx.define("agent:get-models", () => buildModels());
|
|
@@ -365,12 +367,14 @@ export default function agentBackend(ctx) {
|
|
|
365
367
|
bus.emit("config:switch-model", { id: pendingModel, provider: pendingProvider });
|
|
366
368
|
}
|
|
367
369
|
});
|
|
368
|
-
bus.on("provider:configure", ({ id, reasoningParams, cacheTokens }) => {
|
|
370
|
+
bus.on("provider:configure", ({ id, reasoningParams, cacheTokens, requestHeaders }) => {
|
|
369
371
|
const prev = providerHooks.get(id) ?? {};
|
|
370
372
|
if (reasoningParams !== undefined)
|
|
371
373
|
prev.reasoningParams = reasoningParams;
|
|
372
374
|
if (cacheTokens !== undefined)
|
|
373
375
|
prev.cacheTokens = cacheTokens;
|
|
376
|
+
if (requestHeaders !== undefined)
|
|
377
|
+
prev.requestHeaders = requestHeaders;
|
|
374
378
|
providerHooks.set(id, prev);
|
|
375
379
|
});
|
|
376
380
|
bus.on("core:extensions-loaded", ({ names }) => {
|
|
@@ -39,6 +39,8 @@ export type StreamOpts = {
|
|
|
39
39
|
model?: string;
|
|
40
40
|
max_tokens?: number;
|
|
41
41
|
signal?: AbortSignal;
|
|
42
|
+
/** Per-request transport headers, forwarded to the SDK (not request body). */
|
|
43
|
+
headers?: Record<string, string>;
|
|
42
44
|
} & Record<string, unknown>;
|
|
43
45
|
export type CompleteOpts = {
|
|
44
46
|
messages: ChatCompletionMessageParam[];
|
package/dist/agent/llm-client.js
CHANGED
|
@@ -43,7 +43,7 @@ export class LlmClient {
|
|
|
43
43
|
this.model = newConfig.model;
|
|
44
44
|
}
|
|
45
45
|
stream(opts) {
|
|
46
|
-
const { signal, messages, tools, model, max_tokens, ...rest } = opts;
|
|
46
|
+
const { signal, headers, messages, tools, model, max_tokens, ...rest } = opts;
|
|
47
47
|
const body = {
|
|
48
48
|
...rest,
|
|
49
49
|
model: model ?? this.model,
|
|
@@ -53,7 +53,7 @@ export class LlmClient {
|
|
|
53
53
|
stream: true,
|
|
54
54
|
stream_options: { include_usage: true },
|
|
55
55
|
};
|
|
56
|
-
return this.client.chat.completions.create(body, { signal });
|
|
56
|
+
return this.client.chat.completions.create(body, { signal, headers });
|
|
57
57
|
}
|
|
58
58
|
async complete(opts) {
|
|
59
59
|
const { messages, model, max_tokens, ...rest } = opts;
|
|
@@ -24,7 +24,17 @@ function toModalities(input) {
|
|
|
24
24
|
}
|
|
25
25
|
export default function activate(ctx) {
|
|
26
26
|
const apiKey = resolveApiKey("openrouter").key;
|
|
27
|
-
ctx.agent.providers.configure("openrouter", {
|
|
27
|
+
ctx.agent.providers.configure("openrouter", {
|
|
28
|
+
reasoningParams: buildReasoningParams,
|
|
29
|
+
// x-session-id pins sticky provider routing across turns so prompt caches
|
|
30
|
+
// stay warm even when compaction rewrites the opening messages.
|
|
31
|
+
requestHeaders: ({ sessionId }) => {
|
|
32
|
+
const headers = {};
|
|
33
|
+
if (sessionId)
|
|
34
|
+
headers["x-session-id"] = sessionId;
|
|
35
|
+
return headers;
|
|
36
|
+
},
|
|
37
|
+
});
|
|
28
38
|
ctx.agent.providers.register({
|
|
29
39
|
id: "openrouter",
|
|
30
40
|
apiKey: apiKey ?? undefined,
|
|
@@ -57,7 +57,8 @@ export class InputHandler {
|
|
|
57
57
|
loadHistory() {
|
|
58
58
|
try {
|
|
59
59
|
const data = fs.readFileSync(HISTORY_FILE, "utf-8");
|
|
60
|
-
this.history = data.split("\n").filter(Boolean)
|
|
60
|
+
this.history = data.split("\n").filter(Boolean)
|
|
61
|
+
.map((l) => l.replace(/\\([\\n])/g, (_, c) => c === "n" ? "\n" : "\\"));
|
|
61
62
|
}
|
|
62
63
|
catch {
|
|
63
64
|
}
|
|
@@ -66,7 +67,8 @@ export class InputHandler {
|
|
|
66
67
|
try {
|
|
67
68
|
const { historySize } = getSettings();
|
|
68
69
|
fs.mkdirSync(path.dirname(HISTORY_FILE), { recursive: true });
|
|
69
|
-
const lines = this.history.slice(-historySize)
|
|
70
|
+
const lines = this.history.slice(-historySize)
|
|
71
|
+
.map((l) => l.replace(/\\/g, "\\\\").replace(/\n/g, "\\n"));
|
|
70
72
|
fs.writeFileSync(HISTORY_FILE, lines.join("\n") + "\n");
|
|
71
73
|
}
|
|
72
74
|
catch {
|
|
@@ -373,7 +375,7 @@ export class InputHandler {
|
|
|
373
375
|
this.editor.clear();
|
|
374
376
|
this.view.resetCursor();
|
|
375
377
|
this.dismissAutocomplete();
|
|
376
|
-
if (query && query.startsWith("/")) {
|
|
378
|
+
if (query && query.startsWith("/") && !query.includes("\n")) {
|
|
377
379
|
const spaceIdx = query.indexOf(" ");
|
|
378
380
|
const name = spaceIdx === -1 ? query : query.slice(0, spaceIdx);
|
|
379
381
|
const args = spaceIdx === -1 ? "" : query.slice(spaceIdx + 1).trim();
|
|
@@ -40,8 +40,16 @@ export const bashStrategy = {
|
|
|
40
40
|
' [[ $__agent_sh_preexec_ran == 1 ]] && return',
|
|
41
41
|
' [[ -n $COMP_LINE ]] && return',
|
|
42
42
|
" __agent_sh_preexec_ran=1",
|
|
43
|
-
" local this_cmd",
|
|
44
|
-
`
|
|
43
|
+
" local this_cmd hist_cmd",
|
|
44
|
+
` hist_cmd=$(HISTTIMEFORMAT='' builtin history 1 | command sed 's/^ *[0-9]* *//')`,
|
|
45
|
+
" # history 1 carries the full typed line but goes stale when the user's",
|
|
46
|
+
" # PROMPT_COMMAND reloads history (history -c/-r). Trust it only when it",
|
|
47
|
+
" # matches the command bash is about to run; else use $BASH_COMMAND.",
|
|
48
|
+
' if [[ -n $hist_cmd && $hist_cmd == "$BASH_COMMAND"* ]]; then',
|
|
49
|
+
" this_cmd=$hist_cmd",
|
|
50
|
+
" else",
|
|
51
|
+
" this_cmd=$BASH_COMMAND",
|
|
52
|
+
" fi",
|
|
45
53
|
` printf '\\e]9997;${instanceTag};%s\\a' "$this_cmd"`,
|
|
46
54
|
"}",
|
|
47
55
|
"trap '__agent_sh_emit_preexec' DEBUG",
|
package/dist/shell/terminal.d.ts
CHANGED
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Terminal — the user-facing I/O endpoint that a Shell talks to.
|
|
3
|
-
*
|
|
4
|
-
* Shell wraps a *pseudo*-terminal (the PTY the child shell sees). This
|
|
5
|
-
* interface is the *real* terminal (or its substitute) on the other end:
|
|
6
|
-
* bytes in, bytes out, dimensions, resize notifications. The default
|
|
7
|
-
* factory wires it to process.stdin/stdout for the CLI; headless hosts
|
|
8
|
-
* (multi-session web hubs, tests) supply their own.
|
|
9
|
-
*/
|
|
10
1
|
import type { RenderSurface } from "../utils/compositor.js";
|
|
11
2
|
export interface Terminal {
|
|
12
3
|
write(data: string): void;
|
|
@@ -23,8 +14,8 @@ export interface Terminal {
|
|
|
23
14
|
resume(): void;
|
|
24
15
|
};
|
|
25
16
|
}
|
|
26
|
-
/** Default Terminal: wraps process.stdin/stdout. */
|
|
27
|
-
export declare function processTerminal(): Terminal;
|
|
17
|
+
/** Default Terminal: wraps process.stdin/stdout (injectable for tests). */
|
|
18
|
+
export declare function processTerminal(stdin?: NodeJS.ReadStream, stdout?: NodeJS.WriteStream): Terminal;
|
|
28
19
|
/**
|
|
29
20
|
* No-op terminal for non-rendering hosts (tests, agent-only embeds).
|
|
30
21
|
* Writes are discarded; input/resize never fire.
|
package/dist/shell/terminal.js
CHANGED
|
@@ -1,42 +1,60 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Terminal — the user-facing I/O endpoint that a Shell talks to.
|
|
3
|
+
*
|
|
4
|
+
* Shell wraps a *pseudo*-terminal (the PTY the child shell sees). This
|
|
5
|
+
* interface is the *real* terminal (or its substitute) on the other end:
|
|
6
|
+
* bytes in, bytes out, dimensions, resize notifications. The default
|
|
7
|
+
* factory wires it to process.stdin/stdout for the CLI; headless hosts
|
|
8
|
+
* (multi-session web hubs, tests) supply their own.
|
|
9
|
+
*/
|
|
10
|
+
import { StringDecoder } from "node:string_decoder";
|
|
11
|
+
/** Default Terminal: wraps process.stdin/stdout (injectable for tests). */
|
|
12
|
+
export function processTerminal(stdin = process.stdin, stdout = process.stdout) {
|
|
3
13
|
return {
|
|
4
14
|
write(data) {
|
|
5
|
-
if (
|
|
15
|
+
if (stdout.writable) {
|
|
6
16
|
try {
|
|
7
|
-
|
|
17
|
+
stdout.write(data);
|
|
8
18
|
}
|
|
9
19
|
catch { /* ignore */ }
|
|
10
20
|
}
|
|
11
21
|
},
|
|
12
22
|
onInput(cb) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
23
|
+
// Stateful decode: tty chunk boundaries can land mid-way through a
|
|
24
|
+
// multibyte UTF-8 sequence (large pastes), so per-chunk toString()
|
|
25
|
+
// would emit U+FFFD for the torn halves.
|
|
26
|
+
const decoder = new StringDecoder("utf-8");
|
|
27
|
+
const handler = (b) => {
|
|
28
|
+
const text = decoder.write(b);
|
|
29
|
+
if (text)
|
|
30
|
+
cb(text);
|
|
31
|
+
};
|
|
32
|
+
stdin.on("data", handler);
|
|
33
|
+
return () => { stdin.off("data", handler); };
|
|
16
34
|
},
|
|
17
35
|
onResize(cb) {
|
|
18
|
-
const handler = () => cb(
|
|
19
|
-
|
|
20
|
-
return () => {
|
|
36
|
+
const handler = () => cb(stdout.columns || 80, stdout.rows || 24);
|
|
37
|
+
stdout.on("resize", handler);
|
|
38
|
+
return () => { stdout.off("resize", handler); };
|
|
21
39
|
},
|
|
22
|
-
cols() { return
|
|
23
|
-
rows() { return
|
|
40
|
+
cols() { return stdout.columns || 80; },
|
|
41
|
+
rows() { return stdout.rows || 24; },
|
|
24
42
|
suspendInput() {
|
|
25
|
-
const wasRaw =
|
|
26
|
-
if (
|
|
43
|
+
const wasRaw = stdin.isTTY && stdin.isRaw;
|
|
44
|
+
if (stdin.isTTY) {
|
|
27
45
|
try {
|
|
28
|
-
|
|
29
|
-
|
|
46
|
+
stdin.setRawMode(false);
|
|
47
|
+
stdin.pause();
|
|
30
48
|
}
|
|
31
49
|
catch { /* ignore */ }
|
|
32
50
|
}
|
|
33
51
|
return {
|
|
34
52
|
resume() {
|
|
35
|
-
if (
|
|
53
|
+
if (stdin.isTTY) {
|
|
36
54
|
try {
|
|
37
|
-
|
|
55
|
+
stdin.resume();
|
|
38
56
|
if (wasRaw)
|
|
39
|
-
|
|
57
|
+
stdin.setRawMode(true);
|
|
40
58
|
}
|
|
41
59
|
catch { /* ignore */ }
|
|
42
60
|
}
|
|
@@ -845,7 +845,7 @@ export default function activate(ctx) {
|
|
|
845
845
|
? getSettings().readOutputMaxLines
|
|
846
846
|
: getSettings().maxCommandOutputLines;
|
|
847
847
|
s.commandOutputBuffer += chunk;
|
|
848
|
-
const lines = s.commandOutputBuffer.split(
|
|
848
|
+
const lines = s.commandOutputBuffer.split(/\r?\n/);
|
|
849
849
|
s.commandOutputBuffer = lines.pop();
|
|
850
850
|
for (const line of lines) {
|
|
851
851
|
if (s.commandOutputLineCount < maxLines) {
|
package/dist/utils/ansi.d.ts
CHANGED
|
@@ -44,3 +44,10 @@ export declare function padEndToWidth(str: string, targetWidth: number): string;
|
|
|
44
44
|
* CSI, private-mode, 8-bit CSI, and newer variants). `\r` is not an escape
|
|
45
45
|
* but callers rely on it being stripped alongside. */
|
|
46
46
|
export declare function stripAnsi(str: string): string;
|
|
47
|
+
/**
|
|
48
|
+
* Sanitize text for painting at a fixed screen position: SGR (color/style)
|
|
49
|
+
* passes through; anything else that would move the cursor or mutate
|
|
50
|
+
* terminal state mid-row is dropped. Tabs become a single space so painted
|
|
51
|
+
* width matches `stripAnsi`-based measurement.
|
|
52
|
+
*/
|
|
53
|
+
export declare function stripCursorControls(str: string): string;
|
package/dist/utils/ansi.js
CHANGED
|
@@ -134,3 +134,23 @@ export function padEndToWidth(str, targetWidth) {
|
|
|
134
134
|
export function stripAnsi(str) {
|
|
135
135
|
return stripAnsiPkg(str).replace(/\r/g, "");
|
|
136
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* Sanitize text for painting at a fixed screen position: SGR (color/style)
|
|
139
|
+
* passes through; anything else that would move the cursor or mutate
|
|
140
|
+
* terminal state mid-row is dropped. Tabs become a single space so painted
|
|
141
|
+
* width matches `stripAnsi`-based measurement.
|
|
142
|
+
*/
|
|
143
|
+
export function stripCursorControls(str) {
|
|
144
|
+
// Park SGR behind NUL placeholders so the strips below can't eat their ESC bytes.
|
|
145
|
+
const sgr = [];
|
|
146
|
+
const cleaned = str
|
|
147
|
+
.replace(/\x00/g, "")
|
|
148
|
+
.replace(/\x1b\[[0-9;:]*m/g, (m) => { sgr.push(m); return "\x00"; })
|
|
149
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, "")
|
|
150
|
+
.replace(/\x1b\[[0-9;:?]*[ -/]*[@-~]/g, "")
|
|
151
|
+
.replace(/\x1b./g, "")
|
|
152
|
+
.replace(/\t/g, " ")
|
|
153
|
+
.replace(/[\x01-\x08\x0a-\x1f\x7f]/g, "");
|
|
154
|
+
let i = 0;
|
|
155
|
+
return cleaned.replace(/\x00/g, () => sgr[i++] ?? "");
|
|
156
|
+
}
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* Usage from extensions:
|
|
31
31
|
* import { FloatingPanel } from "agent-sh/utils/floating-panel.js";
|
|
32
32
|
*/
|
|
33
|
-
import { stripAnsi } from "./ansi.js";
|
|
33
|
+
import { stripAnsi, stripCursorControls } from "./ansi.js";
|
|
34
34
|
import { wrapLine } from "./markdown.js";
|
|
35
35
|
import { LineEditor } from "./line-editor.js";
|
|
36
36
|
import { TerminalBuffer } from "./terminal-buffer.js";
|
|
@@ -253,10 +253,11 @@ export class FloatingPanel {
|
|
|
253
253
|
this.handlers.define(`${p}:input`, (_data) => false);
|
|
254
254
|
// Default row builder: truncate and pad
|
|
255
255
|
this.handlers.define(`${p}:build-row`, (content, width) => {
|
|
256
|
-
const
|
|
256
|
+
const clean = stripCursorControls(content);
|
|
257
|
+
const plain = stripAnsi(clean);
|
|
257
258
|
const display = plain.length > width
|
|
258
|
-
?
|
|
259
|
-
:
|
|
259
|
+
? clean.slice(0, width - 1) + "\u2026"
|
|
260
|
+
: clean;
|
|
260
261
|
const pad = Math.max(0, width - stripAnsi(display).length);
|
|
261
262
|
return display + " ".repeat(pad);
|
|
262
263
|
});
|
|
@@ -121,7 +121,7 @@ export class LineEditor {
|
|
|
121
121
|
// paste, since typed input arrives one keystroke per chunk in raw mode.
|
|
122
122
|
if (!this.inPaste && data.length > 1 && /[\r\n]/.test(data)
|
|
123
123
|
&& data.indexOf("\x1b[200~") === -1) {
|
|
124
|
-
this.pasteAccum = data
|
|
124
|
+
this.pasteAccum = data;
|
|
125
125
|
actions.push(...this.commitPaste());
|
|
126
126
|
return actions;
|
|
127
127
|
}
|
|
@@ -247,7 +247,7 @@ export class LineEditor {
|
|
|
247
247
|
consumePasteChunk(data) {
|
|
248
248
|
const endIdx = data.indexOf(PASTE_END);
|
|
249
249
|
if (endIdx !== -1) {
|
|
250
|
-
this.pasteAccum += data.slice(0, endIdx)
|
|
250
|
+
this.pasteAccum += data.slice(0, endIdx);
|
|
251
251
|
return endIdx;
|
|
252
252
|
}
|
|
253
253
|
let suffixLen = 0;
|
|
@@ -258,14 +258,17 @@ export class LineEditor {
|
|
|
258
258
|
}
|
|
259
259
|
}
|
|
260
260
|
const safeEnd = data.length - suffixLen;
|
|
261
|
-
this.pasteAccum += data.slice(0, safeEnd)
|
|
261
|
+
this.pasteAccum += data.slice(0, safeEnd);
|
|
262
262
|
if (suffixLen > 0)
|
|
263
263
|
this.pendingSeq = data.slice(safeEnd);
|
|
264
264
|
return -1;
|
|
265
265
|
}
|
|
266
266
|
commitPaste() {
|
|
267
267
|
this.inPaste = false;
|
|
268
|
-
|
|
268
|
+
// Pasted line separators arrive as \n, \r (xterm convention), or \r\n
|
|
269
|
+
// depending on terminal; normalize on the full accumulation so a \r\n
|
|
270
|
+
// pair split across chunks still collapses to one newline.
|
|
271
|
+
const accum = this.pasteAccum.replace(/\r\n?/g, "\n");
|
|
269
272
|
this.pasteAccum = "";
|
|
270
273
|
if (!accum)
|
|
271
274
|
return [];
|
|
@@ -38,7 +38,9 @@ export class AssistantMessage {
|
|
|
38
38
|
if (this.buffer === "") this.buffer = " ";
|
|
39
39
|
const blocks = this.transform([{ type: "text", text: this.buffer }]);
|
|
40
40
|
if (blocks.every((b) => b.type === "text")) {
|
|
41
|
-
|
|
41
|
+
// Render the transformed text, not the raw buffer — transforms may rewrite
|
|
42
|
+
// content in place (e.g. inline-image sentinels) while staying all-text.
|
|
43
|
+
this.md.setText(stripTrailing(blocks.map((b) => (b.type === "text" ? b.text : "")).join("")));
|
|
42
44
|
return;
|
|
43
45
|
}
|
|
44
46
|
this.rebuild(blocks);
|
|
@@ -34,6 +34,7 @@ import { registerCapture, type Capture } from "./capture.js";
|
|
|
34
34
|
import { registerRenderDefaults } from "./hooks.js";
|
|
35
35
|
import { registerDefaultSchemaRenderers } from "./default-schema-renderers.js";
|
|
36
36
|
import { createPiTuiRenderer } from "./renderers/pi-tui/index.js";
|
|
37
|
+
import { registerInlineImage, supportsInlineImages } from "./renderers/pi-tui/inline-image.js";
|
|
37
38
|
import type { Renderer } from "./renderer.js";
|
|
38
39
|
import { loadRendererPreference } from "./display-config.js";
|
|
39
40
|
import { applyOutputMode } from "./terminal-mode.js";
|
|
@@ -177,6 +178,8 @@ async function main(): Promise<void> {
|
|
|
177
178
|
|
|
178
179
|
const ctx = core.extensionContext({ quit: cleanup });
|
|
179
180
|
|
|
181
|
+
ctx.define("session:current-id", () => store.current().id);
|
|
182
|
+
|
|
180
183
|
activateAgent(ctx);
|
|
181
184
|
activateShellContext(ctx);
|
|
182
185
|
await loadBuiltinExtensions(ctx);
|
|
@@ -238,6 +241,11 @@ async function main(): Promise<void> {
|
|
|
238
241
|
registerRenderDefaults(ctx, renderer);
|
|
239
242
|
registerDefaultSchemaRenderers(ctx);
|
|
240
243
|
|
|
244
|
+
// Handler presence is how producers detect that inline images are available.
|
|
245
|
+
if (rendererName === "pi-tui" && supportsInlineImages()) {
|
|
246
|
+
ctx.define("ashi:inline-image:register", (png: Buffer) => registerInlineImage(png));
|
|
247
|
+
}
|
|
248
|
+
|
|
241
249
|
ctx.advise("system-prompt:frontend", (next) => {
|
|
242
250
|
const base = (next() as string) ?? "";
|
|
243
251
|
return base ? `${base}\n\n${ASHI_SURFACE}` : ASHI_SURFACE;
|
|
@@ -399,7 +399,7 @@ export function mountAshi(
|
|
|
399
399
|
app.requestRender();
|
|
400
400
|
return;
|
|
401
401
|
}
|
|
402
|
-
pendingUserShell.push({ private: !!opts?.private });
|
|
402
|
+
pendingUserShell.push({ private: !!opts?.private, command: line });
|
|
403
403
|
if (opts?.private) bus.emit("shell:user-exec-exclude-next", {});
|
|
404
404
|
bus.emit("shell:pty-write", { data: line + "\n" });
|
|
405
405
|
};
|
|
@@ -816,12 +816,13 @@ export function mountAshi(
|
|
|
816
816
|
bus.on("shell:foreground-busy", ({ busy }) => { shellForegroundBusy = busy; });
|
|
817
817
|
|
|
818
818
|
let activeUserShell: { pair: ToolPair; command: string; isPrivate: boolean } | null = null;
|
|
819
|
-
bus.on("shell:command-start", (
|
|
819
|
+
bus.on("shell:command-start", () => {
|
|
820
820
|
if (agentShellActive) return;
|
|
821
821
|
const intent = pendingUserShell.consume();
|
|
822
822
|
if (!intent) return;
|
|
823
823
|
finalizeThinking();
|
|
824
824
|
if (activeAssistant) { activeAssistant.finalize(); activeAssistant = null; }
|
|
825
|
+
const command = intent.command;
|
|
825
826
|
const isPrivate = intent.private;
|
|
826
827
|
const name = isPrivate ? "user_bash_private" : "user_bash";
|
|
827
828
|
const pair = renderToolPair({
|
|
@@ -861,7 +862,7 @@ export function mountAshi(
|
|
|
861
862
|
// Drain shell queue before queries so its output lands in the next turn's <shell_events>.
|
|
862
863
|
while (queuedShellLines.length > 0) {
|
|
863
864
|
const item = queuedShellLines.shift()!;
|
|
864
|
-
pendingUserShell.push({ private: item.private });
|
|
865
|
+
pendingUserShell.push({ private: item.private, command: item.line });
|
|
865
866
|
if (item.private) bus.emit("shell:user-exec-exclude-next", {});
|
|
866
867
|
bus.emit("shell:pty-write", { data: item.line + "\n" });
|
|
867
868
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline images in markdown text via the kitty/Ghostty Unicode placeholder
|
|
3
|
+
* protocol. The image is transmitted out-of-band (cursor-neutral); the frame
|
|
4
|
+
* carries only `cols` width-1 placeholder cells, so the renderer's wrap/measure
|
|
5
|
+
* math is unaffected and the line is never treated as an image line. kitty-only.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
allocateImageId,
|
|
9
|
+
getCapabilities,
|
|
10
|
+
getCellDimensions,
|
|
11
|
+
getImageDimensions,
|
|
12
|
+
} from "@earendil-works/pi-tui";
|
|
13
|
+
|
|
14
|
+
export const SENTINEL_RE = /\x01LI:(\d+)\x01/g;
|
|
15
|
+
export const PLACEHOLDER = String.fromCodePoint(0x10eeee);
|
|
16
|
+
|
|
17
|
+
// kitty rowcolumn-diacritics: the Nth entry encodes row/column index N
|
|
18
|
+
// (gen/rowcolumn-diacritics.txt in the kitty source).
|
|
19
|
+
const DIACRITICS = [0x0305,0x030D,0x030E,0x0310,0x0312,0x033D,0x033E,0x033F,0x0346,0x034A,0x034B,0x034C,0x0350,0x0351,0x0352,0x0357,0x035B,0x0363,0x0364,0x0365,0x0366,0x0367,0x0368,0x0369,0x036A,0x036B,0x036C,0x036D,0x036E,0x036F,0x0483,0x0484,0x0485,0x0486,0x0487,0x0592,0x0593,0x0594,0x0595,0x0597,0x0598,0x0599,0x059C,0x059D,0x059E,0x059F,0x05A0,0x05A1,0x05A8,0x05A9,0x05AB,0x05AC,0x05AF,0x05C4,0x0610,0x0611,0x0612,0x0613,0x0614,0x0615,0x0616,0x0617,0x0657,0x0658,0x0659,0x065A,0x065B,0x065D,0x065E,0x06D6,0x06D7,0x06D8,0x06D9,0x06DA,0x06DB,0x06DC,0x06DF,0x06E0,0x06E1,0x06E2,0x06E4,0x06E7,0x06E8,0x06EB,0x06EC,0x0730,0x0732,0x0733,0x0735,0x0736,0x073A,0x073D,0x073F,0x0740,0x0741,0x0743,0x0745,0x0747,0x0749,0x074A,0x07EB,0x07EC,0x07ED,0x07EE,0x07EF,0x07F0,0x07F1,0x07F3,0x0816,0x0817,0x0818,0x0819,0x081B,0x081C,0x081D,0x081E,0x081F,0x0820,0x0821,0x0822,0x0823,0x0825,0x0826,0x0827,0x0829,0x082A,0x082B,0x082C,0x082D,0x0951,0x0953,0x0954,0x0F82,0x0F83,0x0F86,0x0F87,0x135D,0x135E,0x135F,0x17DD,0x193A,0x1A17,0x1A75,0x1A76,0x1A77,0x1A78,0x1A79,0x1A7A,0x1A7B,0x1A7C,0x1B6B,0x1B6D,0x1B6E,0x1B6F,0x1B70,0x1B71,0x1B72,0x1B73,0x1CD0,0x1CD1,0x1CD2,0x1CDA,0x1CDB,0x1CE0,0x1DC0,0x1DC1,0x1DC3,0x1DC4,0x1DC5,0x1DC6,0x1DC7,0x1DC8,0x1DC9,0x1DCB,0x1DCC,0x1DD1,0x1DD2,0x1DD3,0x1DD4,0x1DD5,0x1DD6,0x1DD7,0x1DD8,0x1DD9,0x1DDA,0x1DDB,0x1DDC,0x1DDD,0x1DDE,0x1DDF,0x1DE0,0x1DE1,0x1DE2,0x1DE3,0x1DE4,0x1DE5,0x1DE6,0x1DFE,0x20D0,0x20D1,0x20D4,0x20D5,0x20D6,0x20D7,0x20DB,0x20DC,0x20E1,0x20E7,0x20E9,0x20F0,0x2CEF,0x2CF0,0x2CF1,0x2DE0,0x2DE1,0x2DE2,0x2DE3,0x2DE4,0x2DE5,0x2DE6,0x2DE7,0x2DE8,0x2DE9,0x2DEA,0x2DEB,0x2DEC,0x2DED,0x2DEE,0x2DEF,0x2DF0,0x2DF1,0x2DF2,0x2DF3,0x2DF4,0x2DF5,0x2DF6,0x2DF7,0x2DF8,0x2DF9,0x2DFA,0x2DFB,0x2DFC,0x2DFD,0x2DFE,0x2DFF,0xA66F,0xA67C,0xA67D,0xA6F0,0xA6F1,0xA8E0,0xA8E1,0xA8E2,0xA8E3,0xA8E4,0xA8E5,0xA8E6,0xA8E7,0xA8E8,0xA8E9,0xA8EA,0xA8EB,0xA8EC,0xA8ED,0xA8EE,0xA8EF,0xA8F0,0xA8F1,0xAAB0,0xAAB2,0xAAB3,0xAAB7,0xAAB8,0xAABE,0xAABF,0xAAC1,0xFE20,0xFE21,0xFE22,0xFE23,0xFE24,0xFE25,0xFE26,0x10A0F,0x10A38,0x1D185,0x1D186,0x1D187,0x1D188,0x1D189,0x1D1AA,0x1D1AB,0x1D1AC,0x1D1AD,0x1D242,0x1D243,0x1D244];
|
|
20
|
+
|
|
21
|
+
const ESC = "\x1b";
|
|
22
|
+
const diacritic = (i: number): string =>
|
|
23
|
+
String.fromCodePoint(DIACRITICS[Math.min(i, DIACRITICS.length - 1)]!);
|
|
24
|
+
|
|
25
|
+
interface Entry {
|
|
26
|
+
base64: string;
|
|
27
|
+
widthPx: number;
|
|
28
|
+
heightPx: number;
|
|
29
|
+
transmitted: boolean;
|
|
30
|
+
placedCols: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const registry = new Map<number, Entry>();
|
|
34
|
+
|
|
35
|
+
export function supportsInlineImages(): boolean {
|
|
36
|
+
return getCapabilities().images === "kitty";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function registerInlineImage(png: Buffer): number | null {
|
|
40
|
+
if (!supportsInlineImages()) return null;
|
|
41
|
+
const base64 = png.toString("base64");
|
|
42
|
+
const dims = getImageDimensions(base64, "image/png");
|
|
43
|
+
if (!dims || dims.heightPx <= 0) return null;
|
|
44
|
+
const id = allocateImageId();
|
|
45
|
+
registry.set(id, { base64, widthPx: dims.widthPx, heightPx: dims.heightPx, transmitted: false, placedCols: 0 });
|
|
46
|
+
return id;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const maxInlineCols = (): number => Math.max(8, (process.stdout.columns ?? 80) - 4);
|
|
50
|
+
|
|
51
|
+
export function inlineCols(id: number, maxCols = maxInlineCols()): number | null {
|
|
52
|
+
const e = registry.get(id);
|
|
53
|
+
if (!e) return null;
|
|
54
|
+
const cell = getCellDimensions();
|
|
55
|
+
const cols = Math.round((e.widthPx / e.heightPx) * (cell.heightPx / cell.widthPx));
|
|
56
|
+
// Column index is carried by a diacritic, so cols can't exceed the table.
|
|
57
|
+
return Math.max(1, Math.min(maxCols, DIACRITICS.length, cols));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Transmits once, then re-places only when cols changes (resize). `write` must
|
|
61
|
+
// target the same stream as the composed frame so the data lands before the cells.
|
|
62
|
+
export function emitInlineImage(id: number, cols: number, write: (s: string) => void): void {
|
|
63
|
+
const e = registry.get(id);
|
|
64
|
+
if (!e) return;
|
|
65
|
+
if (!e.transmitted) {
|
|
66
|
+
e.transmitted = true;
|
|
67
|
+
e.placedCols = cols;
|
|
68
|
+
const b64 = e.base64;
|
|
69
|
+
const CHUNK = 4096;
|
|
70
|
+
for (let i = 0; i < b64.length; i += CHUNK) {
|
|
71
|
+
const chunk = b64.slice(i, i + CHUNK);
|
|
72
|
+
const last = i + CHUNK >= b64.length;
|
|
73
|
+
write(
|
|
74
|
+
i === 0
|
|
75
|
+
? `${ESC}_Gq=2,a=T,U=1,f=100,t=d,i=${id},c=${cols},r=1,m=${last ? 0 : 1};${chunk}${ESC}\\`
|
|
76
|
+
: `${ESC}_Gq=2,m=${last ? 0 : 1};${chunk}${ESC}\\`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
} else if (e.placedCols !== cols) {
|
|
80
|
+
e.placedCols = cols;
|
|
81
|
+
write(`${ESC}_Gq=2,a=p,U=1,i=${id},c=${cols},r=1${ESC}\\`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Each cell encodes the id's low 24 bits in the fg colour and its high byte in the
|
|
86
|
+
// third diacritic (row, column, id-high) — allocateImageId() ids use all 32 bits.
|
|
87
|
+
// `colStart` offsets the column index so an image split across lines (wrap) keeps
|
|
88
|
+
// continuous columns instead of repeating its left edge.
|
|
89
|
+
export function inlinePlaceholder(id: number, count: number, colStart = 0): string {
|
|
90
|
+
const hi = diacritic((id >> 24) & 255);
|
|
91
|
+
let s = `${ESC}[38;2;${(id >> 16) & 255};${(id >> 8) & 255};${id & 255}m`;
|
|
92
|
+
for (let j = 0; j < count; j++) s += PLACEHOLDER + diacritic(0) + diacritic(colStart + j) + hi;
|
|
93
|
+
return s + `${ESC}[39m`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface InlineItem {
|
|
97
|
+
id: number;
|
|
98
|
+
cols: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Repaint the reserved PLACEHOLDER runs as real kitty placeholder cells. Cells are
|
|
102
|
+
// partitioned by each image's reserved `cols` rather than by contiguous run, so
|
|
103
|
+
// adjacent images (`$a$$b$` → one fused run) and an image the wrapper split across
|
|
104
|
+
// lines both keep their ids aligned. `transmit` fires once per image, when its
|
|
105
|
+
// first cell is placed. Pure apart from the injected `transmit` callback.
|
|
106
|
+
export function paintInlineImages(
|
|
107
|
+
lines: string[],
|
|
108
|
+
items: InlineItem[],
|
|
109
|
+
transmit: (id: number, cols: number) => void,
|
|
110
|
+
): string[] {
|
|
111
|
+
if (items.length === 0) return lines;
|
|
112
|
+
let k = 0; // index of the image currently being placed
|
|
113
|
+
let placed = 0; // cells of image k already emitted across prior lines
|
|
114
|
+
return lines.map((line) => {
|
|
115
|
+
if (k >= items.length || !line.includes(PLACEHOLDER)) return line;
|
|
116
|
+
let out = "";
|
|
117
|
+
let i = 0;
|
|
118
|
+
while (i < line.length) {
|
|
119
|
+
const ch = String.fromCodePoint(line.codePointAt(i)!);
|
|
120
|
+
if (ch === PLACEHOLDER && k < items.length) {
|
|
121
|
+
const item = items[k]!;
|
|
122
|
+
let count = 0;
|
|
123
|
+
while (
|
|
124
|
+
i < line.length &&
|
|
125
|
+
String.fromCodePoint(line.codePointAt(i)!) === PLACEHOLDER &&
|
|
126
|
+
placed + count < item.cols
|
|
127
|
+
) {
|
|
128
|
+
count++;
|
|
129
|
+
i += PLACEHOLDER.length;
|
|
130
|
+
}
|
|
131
|
+
if (placed === 0) transmit(item.id, item.cols);
|
|
132
|
+
out += inlinePlaceholder(item.id, count, placed);
|
|
133
|
+
placed += count;
|
|
134
|
+
if (placed >= item.cols) {
|
|
135
|
+
k++;
|
|
136
|
+
placed = 0;
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
out += ch;
|
|
140
|
+
i += ch.length;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
});
|
|
145
|
+
}
|