agent-sh 0.14.10 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -13
- package/dist/agent/agent-loop.d.ts +9 -17
- package/dist/agent/agent-loop.js +123 -150
- package/dist/agent/events.d.ts +10 -12
- package/dist/agent/host-types.d.ts +17 -11
- package/dist/agent/index.d.ts +1 -1
- package/dist/agent/index.js +76 -29
- package/dist/agent/live-view.d.ts +3 -3
- package/dist/agent/live-view.js +15 -7
- package/dist/agent/providers/deepseek.js +9 -1
- package/dist/agent/providers/openrouter.js +9 -0
- package/dist/agent/session-store.js +1 -1
- package/dist/agent/subagent.js +1 -1
- package/dist/agent/system-prompt.d.ts +7 -3
- package/dist/agent/system-prompt.js +11 -14
- package/dist/agent/tool-protocol.js +0 -7
- package/dist/cli/args.js +2 -1
- package/dist/cli/install.d.ts +1 -0
- package/dist/cli/install.js +39 -2
- package/dist/cli/subcommands.js +1 -0
- package/dist/core/event-bus.js +0 -2
- package/dist/core/extension-loader.js +3 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +3 -2
- package/dist/extensions/slash-commands/index.js +16 -11
- package/dist/shell/events.d.ts +3 -0
- package/dist/shell/index.js +9 -0
- package/dist/shell/shell-context.d.ts +2 -2
- package/dist/shell/shell-context.js +26 -11
- package/dist/shell/shell.js +3 -0
- package/dist/shell/tui-renderer.js +0 -1
- package/dist/utils/diff-renderer.d.ts +4 -0
- package/dist/utils/diff-renderer.js +15 -27
- package/dist/utils/handler-registry.d.ts +1 -6
- package/dist/utils/handler-registry.js +1 -6
- package/dist/utils/line-editor.js +0 -2
- package/dist/utils/palette.js +4 -4
- package/dist/utils/terminal-buffer.d.ts +2 -0
- package/dist/utils/terminal-buffer.js +4 -0
- package/examples/extensions/ads/SKILL.md +170 -0
- package/examples/extensions/ash-acp-bridge/src/index.ts +11 -7
- package/examples/extensions/ash-scheme/index.ts +377 -687
- package/examples/extensions/ash-scheme/package.json +1 -1
- package/examples/extensions/ashi/EXTENDING.md +118 -0
- package/examples/extensions/ashi/README.md +26 -54
- package/examples/extensions/ashi/docs/ui-surface-protocol.md +163 -0
- package/examples/extensions/ashi/package.json +14 -2
- package/examples/extensions/ashi/src/autocomplete-controller.ts +95 -0
- package/examples/extensions/ashi/src/autocomplete.ts +1 -23
- package/examples/extensions/ashi/src/capture.ts +54 -10
- package/examples/extensions/ashi/src/chat/assistant.ts +67 -0
- package/examples/extensions/ashi/src/chat/lines.ts +39 -0
- package/examples/extensions/ashi/src/chat/thinking.ts +42 -0
- package/examples/extensions/ashi/src/chat/tool-group.ts +84 -0
- package/examples/extensions/ashi/src/chat/user-message.ts +20 -0
- package/examples/extensions/ashi/src/cli.ts +80 -12
- package/examples/extensions/ashi/src/clipboard-image.ts +41 -0
- package/examples/extensions/ashi/src/commands.ts +11 -1
- package/examples/extensions/ashi/src/dialogs.ts +67 -0
- package/examples/extensions/ashi/src/display-config.ts +16 -1
- package/examples/extensions/ashi/src/docks.ts +31 -0
- package/examples/extensions/ashi/src/events.ts +16 -0
- package/examples/extensions/ashi/src/frontend.ts +456 -268
- package/examples/extensions/ashi/src/hooks.ts +27 -40
- package/examples/extensions/ashi/src/input-prompt.ts +64 -0
- package/examples/extensions/ashi/src/renderer.ts +222 -0
- package/examples/extensions/ashi/src/renderers/pi-tui/app.ts +122 -0
- package/examples/extensions/ashi/src/renderers/pi-tui/index.ts +27 -0
- package/examples/extensions/ashi/src/renderers/pi-tui/nodes.ts +190 -0
- package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +203 -0
- package/examples/extensions/ashi/src/renderers/pi-tui/theme-adapters.ts +48 -0
- package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +21 -0
- package/examples/extensions/ashi/src/schema.ts +46 -205
- package/examples/extensions/ashi/src/session-commands.ts +2 -1
- package/examples/extensions/ashi/src/status-footer.ts +35 -25
- package/examples/extensions/ashi/src/terminal-mode.ts +9 -0
- package/examples/extensions/ashi/src/theme.ts +1 -47
- package/examples/extensions/ashi/src/ui.ts +88 -0
- package/examples/extensions/ashi-ink/README.md +61 -0
- package/examples/extensions/ashi-ink/package.json +30 -0
- package/examples/extensions/ashi-ink/src/index.ts +6 -0
- package/examples/extensions/ashi-ink/src/ink-renderer.tsx +865 -0
- package/examples/extensions/ashi-ink/src/shims.d.ts +5 -0
- package/examples/extensions/ashi-ink/test/render.test.tsx +408 -0
- package/examples/extensions/ashi-ink/tsconfig.json +14 -0
- package/examples/extensions/ashi-scheme-render.ts +10 -10
- package/examples/extensions/ashi-shell-passthrough.ts +95 -0
- package/examples/extensions/ashi-ui-demo.ts +63 -0
- package/examples/extensions/latex-images.ts +70 -19
- package/examples/extensions/overlay-agent.ts +5 -5
- package/examples/extensions/pi-bridge/index.ts +7 -12
- package/examples/extensions/terminal-buffer.ts +4 -2
- package/package.json +3 -9
- package/examples/extensions/ashi/src/components.ts +0 -238
- package/examples/extensions/ollama.ts +0 -108
- package/examples/extensions/opencode-provider.ts +0 -251
- package/examples/extensions/zai-coding-plan.ts +0 -35
|
@@ -2,8 +2,10 @@ import type { ExtensionContext } from "agent-sh/types";
|
|
|
2
2
|
import type { MultiSessionStore } from "./multi-session-store.js";
|
|
3
3
|
import type { AgentShMessage as AgentMessage } from "agent-sh/session-store";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
interface DiffEntry { diff: unknown; filePath: string }
|
|
6
|
+
export interface NestedDiff extends DiffEntry { name: string }
|
|
7
|
+
|
|
8
|
+
// liveEntryIds is parallel to the live messages array; null slots are synthetics (e.g. compaction summaries) with no entry.
|
|
7
9
|
export interface Capture {
|
|
8
10
|
flush(): Promise<void>;
|
|
9
11
|
getEntryIdAt(messageIndex: number): string | null;
|
|
@@ -15,24 +17,59 @@ export function registerCapture(
|
|
|
15
17
|
getStore: () => MultiSessionStore,
|
|
16
18
|
): Capture {
|
|
17
19
|
let liveEntryIds: (string | null)[] = [];
|
|
18
|
-
|
|
20
|
+
// A bridged tool call re-emitted under a synthetic id has no conversation message
|
|
21
|
+
// of its own, so bucket its diff under the enclosing real call for replay as a
|
|
22
|
+
// separate edit pair.
|
|
23
|
+
const diffMeta = new Map<string, DiffEntry>();
|
|
24
|
+
const nestedDiffs = new Map<string, NestedDiff[]>();
|
|
25
|
+
const summaryMeta = new Map<string, string>();
|
|
26
|
+
const bridgedNames = new Map<string, string>();
|
|
27
|
+
let activeRealToolId: string | undefined;
|
|
28
|
+
|
|
29
|
+
// `nested` is a bridge-set bus convention (a host tool run inside another tool),
|
|
30
|
+
// not on the core event type — read it defensively.
|
|
31
|
+
const isNested = (e: unknown): boolean => !!(e as { nested?: boolean }).nested;
|
|
32
|
+
|
|
33
|
+
ctx.bus.on("agent:tool-started", (e) => {
|
|
34
|
+
const id = e.toolCallId;
|
|
35
|
+
if (!id) return;
|
|
36
|
+
if (isNested(e)) bridgedNames.set(id, e.name ?? e.title);
|
|
37
|
+
else activeRealToolId = id;
|
|
38
|
+
});
|
|
19
39
|
|
|
20
40
|
ctx.bus.on("agent:tool-completed", (e) => {
|
|
21
41
|
const id = e.toolCallId;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
42
|
+
if (!id) return;
|
|
43
|
+
const display = e.resultDisplay;
|
|
44
|
+
const body = display?.body;
|
|
45
|
+
if (isNested(e)) {
|
|
46
|
+
if (body?.kind === "diff" && activeRealToolId) {
|
|
47
|
+
const arr = nestedDiffs.get(activeRealToolId) ?? [];
|
|
48
|
+
arr.push({ name: bridgedNames.get(id) ?? "edit_file", diff: body.diff, filePath: body.filePath });
|
|
49
|
+
nestedDiffs.set(activeRealToolId, arr);
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
25
52
|
}
|
|
53
|
+
// resultDisplay isn't persisted; capture the summary for every tool so resume
|
|
54
|
+
// doesn't fall back to re-deriving only a handful.
|
|
55
|
+
if (typeof display?.summary === "string" && display.summary) summaryMeta.set(id, display.summary);
|
|
56
|
+
if (body?.kind === "diff") diffMeta.set(id, { diff: body.diff, filePath: body.filePath });
|
|
26
57
|
});
|
|
27
58
|
|
|
28
59
|
const enrich = (m: AgentMessage): AgentMessage => {
|
|
29
60
|
if (m.role !== "tool" || !m.tool_call_id) return m;
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
61
|
+
const single = diffMeta.get(m.tool_call_id);
|
|
62
|
+
const nested = nestedDiffs.get(m.tool_call_id);
|
|
63
|
+
const summary = summaryMeta.get(m.tool_call_id);
|
|
64
|
+
if (!single && !nested && !summary) return m;
|
|
65
|
+
const meta: Record<string, unknown> = { ...m.meta };
|
|
66
|
+
if (single) { meta.diff = single.diff; meta.filePath = single.filePath; }
|
|
67
|
+
if (nested) meta.diffs = nested;
|
|
68
|
+
if (summary) meta.summary = summary;
|
|
69
|
+
return { ...m, meta };
|
|
33
70
|
};
|
|
34
71
|
|
|
35
|
-
const
|
|
72
|
+
const writeNewMessages = async (): Promise<void> => {
|
|
36
73
|
const messages = ctx.call("conversation:get-messages") as AgentMessage[] | undefined;
|
|
37
74
|
if (!messages || messages.length <= liveEntryIds.length) return;
|
|
38
75
|
const newMessages = messages.slice(liveEntryIds.length).map(enrich);
|
|
@@ -41,6 +78,13 @@ export function registerCapture(
|
|
|
41
78
|
getStore().markLastSession();
|
|
42
79
|
};
|
|
43
80
|
|
|
81
|
+
// Serialize flushes so an exit-time flush can't race processing-done and double-append.
|
|
82
|
+
let chain: Promise<void> = Promise.resolve();
|
|
83
|
+
const flush = (): Promise<void> => {
|
|
84
|
+
chain = chain.then(writeNewMessages, writeNewMessages);
|
|
85
|
+
return chain;
|
|
86
|
+
};
|
|
87
|
+
|
|
44
88
|
ctx.bus.on("agent:processing-done", () => { void flush(); });
|
|
45
89
|
|
|
46
90
|
return {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ContainerView, MarkdownView, RenderNode, RenderNodes } from "../renderer.js";
|
|
2
|
+
|
|
3
|
+
export type RenderBlock =
|
|
4
|
+
| { type: "text"; text: string }
|
|
5
|
+
| { type: "code-block"; language: string; code: string }
|
|
6
|
+
| { type: "image"; data: Buffer };
|
|
7
|
+
|
|
8
|
+
export type ContentTransform = (blocks: RenderBlock[]) => RenderBlock[];
|
|
9
|
+
|
|
10
|
+
export class AssistantMessage {
|
|
11
|
+
readonly node: RenderNode;
|
|
12
|
+
private container: ContainerView;
|
|
13
|
+
private md: MarkdownView;
|
|
14
|
+
private buffer = "";
|
|
15
|
+
|
|
16
|
+
constructor(private nodes: RenderNodes, private transform: ContentTransform = (b) => b) {
|
|
17
|
+
this.container = nodes.container();
|
|
18
|
+
this.md = nodes.markdown({ paddingX: 1, bullet: true });
|
|
19
|
+
this.container.addChild(nodes.spacer(1));
|
|
20
|
+
this.container.addChild(this.md.node);
|
|
21
|
+
this.node = this.container.node;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
appendText(t: string): void {
|
|
25
|
+
this.buffer += t;
|
|
26
|
+
this.md.setText(this.buffer);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
appendCodeBlock(language: string, code: string): void {
|
|
30
|
+
const prefix = this.buffer && !this.buffer.endsWith("\n") ? "\n" : "";
|
|
31
|
+
this.buffer += `${prefix}\`\`\`${language}\n${code}\n\`\`\`\n`;
|
|
32
|
+
this.md.setText(this.buffer);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
finalize(): void {
|
|
36
|
+
if (this.buffer === "") this.buffer = " ";
|
|
37
|
+
const blocks = this.transform([{ type: "text", text: this.buffer }]);
|
|
38
|
+
if (blocks.every((b) => b.type === "text")) {
|
|
39
|
+
this.md.setText(this.buffer);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
this.rebuild(blocks);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private rebuild(blocks: RenderBlock[]): void {
|
|
46
|
+
this.container.clear();
|
|
47
|
+
this.container.addChild(this.nodes.spacer(1));
|
|
48
|
+
for (const block of blocks) {
|
|
49
|
+
if (block.type === "image") {
|
|
50
|
+
const img = this.nodes.image(block.data);
|
|
51
|
+
if (img) this.container.addChild(img);
|
|
52
|
+
} else if (block.type === "code-block") {
|
|
53
|
+
const m = this.nodes.markdown({ paddingX: 1 });
|
|
54
|
+
m.setText(`\`\`\`${block.language}\n${block.code}\n\`\`\``);
|
|
55
|
+
this.container.addChild(m.node);
|
|
56
|
+
} else if (block.text.trim()) {
|
|
57
|
+
const m = this.nodes.markdown({ paddingX: 1 });
|
|
58
|
+
m.setText(block.text);
|
|
59
|
+
this.container.addChild(m.node);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
hasContent(): boolean {
|
|
65
|
+
return this.buffer.trim().length > 0;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { RenderNode, RenderNodes } from "../renderer.js";
|
|
2
|
+
import { theme, type ThemeColor } from "../theme.js";
|
|
3
|
+
|
|
4
|
+
export class InfoLine {
|
|
5
|
+
readonly node: RenderNode;
|
|
6
|
+
constructor(nodes: RenderNodes, message: string) {
|
|
7
|
+
const t = nodes.text({ paddingX: 1 });
|
|
8
|
+
t.setText(theme.fg("muted", message));
|
|
9
|
+
this.node = t.node;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class ErrorLine {
|
|
14
|
+
readonly node: RenderNode;
|
|
15
|
+
constructor(nodes: RenderNodes, message: string) {
|
|
16
|
+
const t = nodes.text({ paddingX: 1 });
|
|
17
|
+
t.setText(`${theme.fg("error", "✗")} ${theme.fg("error", message)}`);
|
|
18
|
+
this.node = t.node;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type NoticeLevel = "info" | "warn" | "error" | "success";
|
|
23
|
+
|
|
24
|
+
const NOTICE: Record<NoticeLevel, { color: ThemeColor; prefix: string }> = {
|
|
25
|
+
info: { color: "muted", prefix: "" },
|
|
26
|
+
success: { color: "success", prefix: "✓ " },
|
|
27
|
+
warn: { color: "warning", prefix: "! " },
|
|
28
|
+
error: { color: "error", prefix: "✗ " },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export class NoticeLine {
|
|
32
|
+
readonly node: RenderNode;
|
|
33
|
+
constructor(nodes: RenderNodes, message: string, level: NoticeLevel = "info") {
|
|
34
|
+
const { color, prefix } = NOTICE[level];
|
|
35
|
+
const t = nodes.text({ paddingX: 1 });
|
|
36
|
+
t.setText(`${theme.fg(color, prefix)}${theme.fg(color, message)}`);
|
|
37
|
+
this.node = t.node;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Hidden clears the block but keeps the buffer so a toggle restores it.
|
|
2
|
+
import type { ContainerView, MarkdownView, RenderNode, RenderNodes } from "../renderer.js";
|
|
3
|
+
import { theme } from "../theme.js";
|
|
4
|
+
|
|
5
|
+
const thinkingColor = (t: string): string => theme.dim(theme.italic(theme.fg("thinkingText", t)));
|
|
6
|
+
|
|
7
|
+
export class ThinkingBlock {
|
|
8
|
+
readonly node: RenderNode;
|
|
9
|
+
private container: ContainerView;
|
|
10
|
+
private md: MarkdownView;
|
|
11
|
+
private buffer = "";
|
|
12
|
+
private hidden = false;
|
|
13
|
+
|
|
14
|
+
constructor(private nodes: RenderNodes) {
|
|
15
|
+
this.container = nodes.container();
|
|
16
|
+
this.md = nodes.markdown({ paddingX: 1, color: thinkingColor });
|
|
17
|
+
this.container.addChild(nodes.spacer(1));
|
|
18
|
+
this.container.addChild(this.md.node);
|
|
19
|
+
this.node = this.container.node;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
appendText(t: string): void {
|
|
23
|
+
this.buffer += t;
|
|
24
|
+
if (!this.hidden) this.md.setText(this.buffer);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
finalize(): void {
|
|
28
|
+
if (this.buffer === "") this.buffer = " ";
|
|
29
|
+
if (!this.hidden) this.md.setText(this.buffer);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
setHidden(hidden: boolean): void {
|
|
33
|
+
if (hidden === this.hidden) return;
|
|
34
|
+
this.hidden = hidden;
|
|
35
|
+
this.container.clear();
|
|
36
|
+
if (hidden) return;
|
|
37
|
+
this.container.addChild(this.nodes.spacer(1));
|
|
38
|
+
this.md = this.nodes.markdown({ paddingX: 1, color: thinkingColor });
|
|
39
|
+
this.md.setText(this.buffer);
|
|
40
|
+
this.container.addChild(this.md.node);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Renderer, RenderNode, ToolGroupChild, ToolGroupModel, ToolGroupView } from "../renderer.js";
|
|
2
|
+
|
|
3
|
+
export const GROUP_ICONS: Record<string, string> = { read: "◆", search: "⌕" };
|
|
4
|
+
|
|
5
|
+
const SHORT_TOOL_NAMES: Record<string, string> = {
|
|
6
|
+
read_file: "read",
|
|
7
|
+
edit_file: "edit",
|
|
8
|
+
write_file: "write",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function shortToolName(name: string): string {
|
|
12
|
+
return SHORT_TOOL_NAMES[name] ?? name;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class ToolGroup {
|
|
16
|
+
readonly node: RenderNode;
|
|
17
|
+
readonly kind: string;
|
|
18
|
+
private view: ToolGroupView;
|
|
19
|
+
private maxVisible: number;
|
|
20
|
+
private allChildren: ToolGroupChild[] = [];
|
|
21
|
+
private callsById = new Map<string, ToolGroupChild>();
|
|
22
|
+
private expanded = false;
|
|
23
|
+
private open = true;
|
|
24
|
+
|
|
25
|
+
constructor(renderer: Renderer, kind: string, maxVisible: number = Infinity) {
|
|
26
|
+
this.kind = kind;
|
|
27
|
+
this.maxVisible = maxVisible;
|
|
28
|
+
this.view = renderer.mountToolGroup!();
|
|
29
|
+
this.node = this.view.node;
|
|
30
|
+
this.repaint();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
addCall(toolCallId: string, name: string, detail: string): void {
|
|
34
|
+
const child: ToolGroupChild = { name: shortToolName(name), detail: detail || "…" };
|
|
35
|
+
if (toolCallId) this.callsById.set(toolCallId, child);
|
|
36
|
+
this.allChildren.push(child);
|
|
37
|
+
this.repaint();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
recordCompletion(toolCallId: string, exitCode: number | null, summary?: string): void {
|
|
41
|
+
const child = this.callsById.get(toolCallId);
|
|
42
|
+
if (!child) return;
|
|
43
|
+
child.status = { exitCode, summary };
|
|
44
|
+
this.repaint();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
toggleExpanded(): void {
|
|
48
|
+
this.expanded = !this.expanded;
|
|
49
|
+
this.repaint();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
seal(): void {
|
|
53
|
+
if (!this.open) return;
|
|
54
|
+
this.open = false;
|
|
55
|
+
this.repaint();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// When collapsed and over-cap, one visible slot is reserved for the summary.
|
|
59
|
+
private visibleSliceStart(): number {
|
|
60
|
+
if (this.expanded || !Number.isFinite(this.maxVisible)) return 0;
|
|
61
|
+
if (this.allChildren.length <= this.maxVisible) return 0;
|
|
62
|
+
return this.allChildren.length - (this.maxVisible - 1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private repaint(): void {
|
|
66
|
+
const start = this.visibleSliceStart();
|
|
67
|
+
const evicted = this.allChildren.slice(0, start);
|
|
68
|
+
const hidden = evicted.length === 0
|
|
69
|
+
? null
|
|
70
|
+
: {
|
|
71
|
+
count: evicted.length,
|
|
72
|
+
ok: evicted.every((c) => !c.status || c.status.exitCode === null || c.status.exitCode === 0),
|
|
73
|
+
};
|
|
74
|
+
const model: ToolGroupModel = {
|
|
75
|
+
kind: this.kind,
|
|
76
|
+
icon: GROUP_ICONS[this.kind] ?? "▶",
|
|
77
|
+
children: this.allChildren.slice(start),
|
|
78
|
+
hidden,
|
|
79
|
+
expanded: this.expanded,
|
|
80
|
+
open: this.open,
|
|
81
|
+
};
|
|
82
|
+
this.view.update(model);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { RenderNode, RenderNodes } from "../renderer.js";
|
|
2
|
+
import { theme } from "../theme.js";
|
|
3
|
+
|
|
4
|
+
export class UserMessage {
|
|
5
|
+
readonly node: RenderNode;
|
|
6
|
+
constructor(nodes: RenderNodes, text: string) {
|
|
7
|
+
const container = nodes.container();
|
|
8
|
+
container.addChild(nodes.spacer(1));
|
|
9
|
+
const md = nodes.markdown({
|
|
10
|
+
paddingX: 1,
|
|
11
|
+
paddingY: 1,
|
|
12
|
+
bgColor: (t) => theme.bg("userMessageBg", t),
|
|
13
|
+
color: (t) => theme.fg("userMessageText", t),
|
|
14
|
+
osc133Zones: true,
|
|
15
|
+
});
|
|
16
|
+
md.setText(text);
|
|
17
|
+
container.addChild(md.node);
|
|
18
|
+
this.node = container.node;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -8,11 +8,12 @@ import { loadExtensions } from "agent-sh/extension-loader";
|
|
|
8
8
|
import { activateAgent } from "agent-sh/agent";
|
|
9
9
|
import { getSettings } from "agent-sh/settings";
|
|
10
10
|
import { Shell } from "agent-sh/shell";
|
|
11
|
+
import { TerminalBuffer } from "agent-sh/utils/terminal-buffer";
|
|
11
12
|
import type { Terminal } from "agent-sh/shell/terminal";
|
|
12
13
|
import activateShellContext from "agent-sh/shell/context";
|
|
13
14
|
import type { AppConfig } from "agent-sh/types";
|
|
14
15
|
|
|
15
|
-
/**
|
|
16
|
+
/** ashi renders through its own Renderer; this PTY only needs to exist. */
|
|
16
17
|
function headlessTerminal(): Terminal {
|
|
17
18
|
return {
|
|
18
19
|
write() {},
|
|
@@ -23,23 +24,41 @@ function headlessTerminal(): Terminal {
|
|
|
23
24
|
};
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
import "./events.js";
|
|
26
28
|
import { mountAshi } from "./frontend.js";
|
|
27
29
|
import { MultiSessionStore } from "./multi-session-store.js";
|
|
28
30
|
import { registerForkCommands, applyBranchMessages } from "./commands.js";
|
|
29
31
|
import { registerSessionCommands } from "./session-commands.js";
|
|
30
32
|
import { registerCompaction } from "./compaction.js";
|
|
31
|
-
import { registerCapture } from "./capture.js";
|
|
33
|
+
import { registerCapture, type Capture } from "./capture.js";
|
|
32
34
|
import { registerRenderDefaults } from "./hooks.js";
|
|
33
35
|
import { registerDefaultSchemaRenderers } from "./default-schema-renderers.js";
|
|
36
|
+
import { createPiTuiRenderer } from "./renderers/pi-tui/index.js";
|
|
37
|
+
import type { Renderer } from "./renderer.js";
|
|
38
|
+
import { loadRendererPreference } from "./display-config.js";
|
|
39
|
+
import { applyOutputMode } from "./terminal-mode.js";
|
|
34
40
|
import * as os from "node:os";
|
|
35
41
|
import * as path from "node:path";
|
|
42
|
+
import { fileURLToPath } from "node:url";
|
|
36
43
|
|
|
37
|
-
|
|
44
|
+
// Package root (dist/cli.js and src/cli.ts both sit one level down) — the running copy.
|
|
45
|
+
const ASHI_ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
46
|
+
const ASHI_SURFACE = `You're attached through ashi, an interactive terminal UI. A person is at the keyboard reading your replies as they render — address them directly and keep the exchange conversational.
|
|
47
|
+
|
|
48
|
+
Your working directory is ${process.cwd()}; your tools run there and it stays fixed. The user can also run shell commands with a \`!\` prefix — those run in a separate shell that may sit elsewhere, and don't change your working directory.
|
|
49
|
+
|
|
50
|
+
ashi's own source lives at ${ASHI_ROOT}. Read it when the user asks how the TUI works, or wants to change how it looks or behaves:
|
|
51
|
+
- ${path.join(ASHI_ROOT, "README.md")} — what ashi is and how rendering decouples into swappable render extensions
|
|
52
|
+
- ${path.join(ASHI_ROOT, "EXTENDING.md")} — the render-extension contract
|
|
53
|
+
- ${path.join(ASHI_ROOT, "src")} — the frontend, session capture/resume, and the pi-tui renderer`;
|
|
54
|
+
|
|
55
|
+
function parseArgs(argv: string[]): AppConfig & { extensions?: string[]; continueLast: boolean; renderer?: string } {
|
|
38
56
|
let model: string | undefined;
|
|
39
57
|
let apiKey: string | undefined;
|
|
40
58
|
let baseURL: string | undefined;
|
|
41
59
|
let provider: string | undefined;
|
|
42
60
|
let backend: string | undefined;
|
|
61
|
+
let renderer: string | undefined;
|
|
43
62
|
let continueLast = false;
|
|
44
63
|
const extensions: string[] = [];
|
|
45
64
|
|
|
@@ -50,6 +69,7 @@ function parseArgs(argv: string[]): AppConfig & { extensions?: string[]; continu
|
|
|
50
69
|
else if (a === "--base-url" && argv[i + 1]) baseURL = argv[++i];
|
|
51
70
|
else if (a === "--provider" && argv[i + 1]) provider = argv[++i];
|
|
52
71
|
else if (a === "--backend" && argv[i + 1]) backend = argv[++i];
|
|
72
|
+
else if (a === "--renderer" && argv[i + 1]) renderer = argv[++i];
|
|
53
73
|
else if ((a === "-e" || a === "--extensions") && argv[i + 1]) {
|
|
54
74
|
extensions.push(...argv[++i]!.split(",").map(s => s.trim()).filter(Boolean));
|
|
55
75
|
} else if (a === "-c" || a === "--continue") {
|
|
@@ -60,7 +80,7 @@ function parseArgs(argv: string[]): AppConfig & { extensions?: string[]; continu
|
|
|
60
80
|
}
|
|
61
81
|
}
|
|
62
82
|
|
|
63
|
-
return { shell: "/bin/sh", model, apiKey, baseURL, provider, backend, extensions, continueLast };
|
|
83
|
+
return { shell: "/bin/sh", model, apiKey, baseURL, provider, backend, renderer, extensions, continueLast };
|
|
64
84
|
}
|
|
65
85
|
|
|
66
86
|
const MANAGEMENT_HELP = `ashi — ash (agent-sh's built-in agent) in an interactive TUI
|
|
@@ -76,9 +96,12 @@ Management:
|
|
|
76
96
|
|
|
77
97
|
Launch (default):
|
|
78
98
|
ashi [--provider <name>] [--model <id>] [--api-key <key>] [--base-url <url>]
|
|
79
|
-
[--backend <name>] [-e <ext>[,<ext>...]] [-c | --continue]
|
|
99
|
+
[--backend <name>] [--renderer <name>] [-e <ext>[,<ext>...]] [-c | --continue]
|
|
80
100
|
|
|
81
101
|
-c, --continue Resume the last session in this cwd (fresh session if none)
|
|
102
|
+
--renderer TUI renderer (default: pi-tui). Also ASHI_RENDERER, or
|
|
103
|
+
ashi.renderer in settings.json. A renderer is an extension;
|
|
104
|
+
install it (or -e it) so its ashi:renderer:<name> is registered.
|
|
82
105
|
|
|
83
106
|
Reads ~/.agent-sh/settings.json for providers and defaults.`;
|
|
84
107
|
|
|
@@ -139,7 +162,11 @@ async function main(): Promise<void> {
|
|
|
139
162
|
let stopFrontend: (() => void) | null = null;
|
|
140
163
|
|
|
141
164
|
let shellRef: { kill(): void } | null = null;
|
|
142
|
-
|
|
165
|
+
let captureRef: Capture | null = null;
|
|
166
|
+
const cleanup = async (): Promise<void> => {
|
|
167
|
+
// The per-turn flush is fire-and-forget; await it so a quick exit can't drop
|
|
168
|
+
// a just-completed turn.
|
|
169
|
+
try { await captureRef?.flush(); } catch {}
|
|
143
170
|
try { stopFrontend?.(); } catch {}
|
|
144
171
|
try { shellRef?.kill(); } catch {}
|
|
145
172
|
try { core.kill(); } catch {}
|
|
@@ -153,7 +180,7 @@ async function main(): Promise<void> {
|
|
|
153
180
|
|
|
154
181
|
activateAgent(ctx);
|
|
155
182
|
activateShellContext(ctx);
|
|
156
|
-
await loadBuiltinExtensions(ctx
|
|
183
|
+
const builtinExtensions = await loadBuiltinExtensions(ctx);
|
|
157
184
|
|
|
158
185
|
const shell = new Shell({
|
|
159
186
|
bus: core.bus,
|
|
@@ -167,6 +194,17 @@ async function main(): Promise<void> {
|
|
|
167
194
|
});
|
|
168
195
|
shellRef = shell;
|
|
169
196
|
|
|
197
|
+
let terminalBuffer: TerminalBuffer | null | undefined;
|
|
198
|
+
ctx.define("terminal-buffer", (): TerminalBuffer | null => {
|
|
199
|
+
if (terminalBuffer !== undefined) return terminalBuffer;
|
|
200
|
+
try {
|
|
201
|
+
terminalBuffer = TerminalBuffer.createWired(core.bus);
|
|
202
|
+
} catch {
|
|
203
|
+
terminalBuffer = null;
|
|
204
|
+
}
|
|
205
|
+
return terminalBuffer;
|
|
206
|
+
});
|
|
207
|
+
|
|
170
208
|
const loaded = await loadExtensions(ctx, config.extensions);
|
|
171
209
|
core.bus.emit("core:extensions-loaded", { names: loaded });
|
|
172
210
|
|
|
@@ -179,29 +217,59 @@ async function main(): Promise<void> {
|
|
|
179
217
|
}
|
|
180
218
|
|
|
181
219
|
const capture = registerCapture(ctx, getStore);
|
|
220
|
+
captureRef = capture;
|
|
182
221
|
registerCompaction(ctx, getStore, capture);
|
|
183
|
-
|
|
222
|
+
|
|
223
|
+
// Renderers are extensions; selection is --renderer > ASHI_RENDERER >
|
|
224
|
+
// ashi.renderer (settings) > pi-tui.
|
|
225
|
+
ctx.define("ashi:renderer:pi-tui", () => createPiTuiRenderer());
|
|
226
|
+
const rendererName = (
|
|
227
|
+
config.renderer ?? process.env.ASHI_RENDERER ?? loadRendererPreference() ?? "pi-tui"
|
|
228
|
+
).trim();
|
|
229
|
+
const rendererKey = `ashi:renderer:${rendererName}`;
|
|
230
|
+
if (!ctx.list().includes(rendererKey)) {
|
|
231
|
+
process.stderr.write(
|
|
232
|
+
`ashi: no renderer registered for "${rendererName}" (${rendererKey}). ` +
|
|
233
|
+
`Install the extension that provides it (or pass -e <ext>) so it registers ${rendererKey}.\n`,
|
|
234
|
+
);
|
|
235
|
+
process.exit(1);
|
|
236
|
+
}
|
|
237
|
+
const renderer = ctx.call(rendererKey) as Renderer;
|
|
238
|
+
applyOutputMode(renderer.capabilities.rawOutput);
|
|
239
|
+
registerRenderDefaults(ctx, renderer);
|
|
184
240
|
registerDefaultSchemaRenderers(ctx);
|
|
185
241
|
|
|
186
|
-
ctx.advise("system-prompt:
|
|
242
|
+
ctx.advise("system-prompt:frontend", (next) => {
|
|
243
|
+
const base = (next() as string) ?? "";
|
|
244
|
+
return base ? `${base}\n\n${ASHI_SURFACE}` : ASHI_SURFACE;
|
|
245
|
+
});
|
|
187
246
|
|
|
188
|
-
const handle = mountAshi(ctx, getStore, capture);
|
|
247
|
+
const handle = mountAshi(ctx, getStore, capture, renderer);
|
|
189
248
|
stopFrontend = handle.stop;
|
|
190
249
|
|
|
250
|
+
core.bus.emit("ashi:ready", {});
|
|
251
|
+
|
|
191
252
|
registerForkCommands(ctx, getStore, handle.openTreePicker, handle.rebuildChat, capture);
|
|
192
253
|
registerSessionCommands(ctx, getStore, capture, {
|
|
193
254
|
openSessionPicker: handle.openSessionPicker,
|
|
194
255
|
rebuildChat: handle.rebuildChat,
|
|
195
256
|
});
|
|
196
257
|
|
|
258
|
+
await core.activateBackend(config.backend ?? getSettings().defaultBackend);
|
|
259
|
+
|
|
197
260
|
if (resumeId) {
|
|
261
|
+
// After activateBackend: conversation:replace-messages is a no-op until the agent backend exists.
|
|
198
262
|
applyBranchMessages(ctx, getStore, capture);
|
|
199
263
|
await handle.rebuildChat();
|
|
200
264
|
ctx.bus.emit("ui:info", { message: `continued session ${resumeId.slice(0, 12)}…` });
|
|
265
|
+
} else {
|
|
266
|
+
// New-session only: skip on resume so a restored transcript isn't prefixed with this.
|
|
267
|
+
const loadedExtensions = [...new Set([...builtinExtensions, ...loaded])];
|
|
268
|
+
if (loadedExtensions.length > 0) {
|
|
269
|
+
ctx.bus.emit("ui:info", { message: `extensions: ${loadedExtensions.join(" · ")}` });
|
|
270
|
+
}
|
|
201
271
|
}
|
|
202
272
|
|
|
203
|
-
await core.activateBackend(config.backend ?? getSettings().defaultBackend);
|
|
204
|
-
|
|
205
273
|
process.on("SIGTERM", cleanup);
|
|
206
274
|
process.on("SIGHUP", cleanup);
|
|
207
275
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { readFile, unlink } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
const exec = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
export interface CapturedImage {
|
|
10
|
+
/** Base64-encoded image data (no data: URL prefix). */
|
|
11
|
+
data: string;
|
|
12
|
+
mimeType: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let counter = 0;
|
|
16
|
+
|
|
17
|
+
/** Terminals don't deliver image bytes through paste, so we read the macOS
|
|
18
|
+
* pasteboard directly via osascript. Null when the clipboard holds no image. */
|
|
19
|
+
export async function readClipboardImage(): Promise<CapturedImage | null> {
|
|
20
|
+
if (process.platform !== "darwin") return null;
|
|
21
|
+
const tmp = join(tmpdir(), `ashi-clip-${process.pid}-${counter++}.png`);
|
|
22
|
+
try {
|
|
23
|
+
await exec("osascript", [
|
|
24
|
+
"-e", "set png_data to (the clipboard as «class PNGf»)",
|
|
25
|
+
"-e", `set fp to open for access POSIX file ${JSON.stringify(tmp)} with write permission`,
|
|
26
|
+
"-e", "write png_data to fp",
|
|
27
|
+
"-e", "close access fp",
|
|
28
|
+
], { timeout: 10_000 });
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const buf = await readFile(tmp);
|
|
34
|
+
if (buf.length === 0) return null;
|
|
35
|
+
return { data: buf.toString("base64"), mimeType: "image/png" };
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
} finally {
|
|
39
|
+
void unlink(tmp).catch(() => {});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -40,7 +40,17 @@ export function applyBranchMessages(
|
|
|
40
40
|
capture: Capture,
|
|
41
41
|
): void {
|
|
42
42
|
const store = getStore().current();
|
|
43
|
-
|
|
43
|
+
const messages = store.buildMessages();
|
|
44
|
+
ctx.call("conversation:replace-messages", messages);
|
|
45
|
+
|
|
46
|
+
// replace-messages no-ops until the agent backend is active; seeding then desyncs
|
|
47
|
+
// capture's baseline against an empty conversation and silently drops later turns.
|
|
48
|
+
const live = ctx.call("conversation:get-messages") as unknown[] | undefined;
|
|
49
|
+
if ((live?.length ?? 0) !== messages.length) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`applyBranchMessages: conversation not seeded (live ${live?.length ?? 0} vs ${messages.length}); call after the agent backend is active`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
44
54
|
|
|
45
55
|
const branch = store.getBranch();
|
|
46
56
|
let compaction: { firstKeptId: string } | null = null;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { App, Renderer } from "./renderer.js";
|
|
2
|
+
import { InfoLine } from "./chat/lines.js";
|
|
3
|
+
|
|
4
|
+
export interface SelectChoice {
|
|
5
|
+
value: string;
|
|
6
|
+
label: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface SelectOpts {
|
|
10
|
+
title?: string;
|
|
11
|
+
items: SelectChoice[];
|
|
12
|
+
}
|
|
13
|
+
export interface ConfirmOpts {
|
|
14
|
+
title: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DialogGuard {
|
|
18
|
+
isOpen(): boolean;
|
|
19
|
+
setOpen(open: boolean): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Dialogs {
|
|
23
|
+
select(opts: SelectOpts): Promise<string | undefined>;
|
|
24
|
+
confirm(opts: ConfirmOpts): Promise<boolean>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createDialogs(app: App, renderer: Renderer, guard: DialogGuard): Dialogs {
|
|
28
|
+
const select = (opts: SelectOpts): Promise<string | undefined> => {
|
|
29
|
+
if (guard.isOpen() || opts.items.length === 0) return Promise.resolve(undefined);
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
const hint = new InfoLine(renderer, opts.title ?? "↑↓ move · enter: select · esc: cancel");
|
|
32
|
+
const picker = app.createSelectList(
|
|
33
|
+
opts.items.map((c) => ({ value: c.value, label: c.label, description: c.description })),
|
|
34
|
+
{ visibleRows: Math.min(15, Math.max(1, opts.items.length)) },
|
|
35
|
+
);
|
|
36
|
+
let settled = false;
|
|
37
|
+
const close = (result?: string): void => {
|
|
38
|
+
if (settled) return;
|
|
39
|
+
settled = true;
|
|
40
|
+
guard.setOpen(false);
|
|
41
|
+
app.footerSlot.removeChild(picker.node);
|
|
42
|
+
app.footerSlot.removeChild(hint.node);
|
|
43
|
+
app.focusInput();
|
|
44
|
+
app.requestRender();
|
|
45
|
+
resolve(result);
|
|
46
|
+
};
|
|
47
|
+
picker.onSelect((item) => close(item.value));
|
|
48
|
+
picker.onCancel(() => close());
|
|
49
|
+
guard.setOpen(true);
|
|
50
|
+
app.footerSlot.addChild(hint.node);
|
|
51
|
+
app.footerSlot.addChild(picker.node);
|
|
52
|
+
app.setFocus(picker.node);
|
|
53
|
+
app.requestRender();
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const confirm = (opts: ConfirmOpts): Promise<boolean> =>
|
|
58
|
+
select({
|
|
59
|
+
title: opts.title,
|
|
60
|
+
items: [
|
|
61
|
+
{ value: "yes", label: "Yes" },
|
|
62
|
+
{ value: "no", label: "No" },
|
|
63
|
+
],
|
|
64
|
+
}).then((v) => v === "yes");
|
|
65
|
+
|
|
66
|
+
return { select, confirm };
|
|
67
|
+
}
|