agent-sh 0.14.11 → 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 +38 -42
- package/dist/agent/agent-loop.d.ts +9 -17
- package/dist/agent/agent-loop.js +104 -136
- package/dist/agent/events.d.ts +8 -11
- package/dist/agent/host-types.d.ts +17 -11
- package/dist/agent/index.d.ts +1 -1
- package/dist/agent/index.js +38 -22
- package/dist/agent/providers/deepseek.js +9 -1
- package/dist/agent/session-store.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 +29 -1
- 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/index.js +9 -0
- package/dist/shell/shell-context.d.ts +2 -2
- package/dist/shell/shell-context.js +26 -11
- package/dist/shell/tui-renderer.js +0 -1
- package/dist/utils/diff-renderer.js +2 -9
- 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/ash-acp-bridge/src/index.ts +11 -7
- package/examples/extensions/ash-scheme/index.ts +104 -74
- package/examples/extensions/ashi/EXTENDING.md +2 -0
- package/examples/extensions/ashi/README.md +17 -1
- package/examples/extensions/ashi/docs/ui-surface-protocol.md +163 -0
- package/examples/extensions/ashi/package.json +9 -1
- package/examples/extensions/ashi/src/capture.ts +45 -7
- package/examples/extensions/ashi/src/chat/assistant.ts +23 -43
- package/examples/extensions/ashi/src/chat/lines.ts +20 -1
- package/examples/extensions/ashi/src/cli.ts +25 -3
- package/examples/extensions/ashi/src/clipboard-image.ts +1 -1
- package/examples/extensions/ashi/src/dialogs.ts +67 -0
- package/examples/extensions/ashi/src/display-config.ts +7 -0
- 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 +134 -27
- package/examples/extensions/ashi/src/hooks.ts +6 -12
- package/examples/extensions/ashi/src/input-prompt.ts +64 -0
- package/examples/extensions/ashi/src/renderers/pi-tui/index.ts +7 -3
- package/examples/extensions/ashi/src/renderers/pi-tui/nodes.ts +67 -10
- package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +11 -1
- package/examples/extensions/ashi/src/schema.ts +3 -0
- package/examples/extensions/ashi/src/session-commands.ts +2 -1
- package/examples/extensions/ashi/src/status-footer.ts +21 -3
- package/examples/extensions/ashi/src/ui.ts +88 -0
- package/examples/extensions/ashi-ink/README.md +2 -0
- package/examples/extensions/ashi-scheme-render.ts +8 -2
- package/examples/extensions/ashi-ui-demo.ts +63 -0
- package/examples/extensions/latex-images.ts +57 -9
- package/examples/extensions/overlay-agent.ts +5 -5
- package/examples/extensions/pi-bridge/index.ts +7 -12
- package/package.json +1 -1
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# ashi UI surfaces
|
|
2
|
+
|
|
3
|
+
How an extension drives ashi's terminal UI — post a notice, add a status-bar segment, pin a
|
|
4
|
+
widget above the input, or ask the user a question.
|
|
5
|
+
|
|
6
|
+
ashi doesn't hand extensions a `ui` object. Instead it answers a small set of **bus events**
|
|
7
|
+
and **named handlers**, so the same extension degrades gracefully under a frontend that doesn't
|
|
8
|
+
implement a given surface (and under headless/RPC use). There are three shapes:
|
|
9
|
+
|
|
10
|
+
- **Notices** are fire-and-forget bus events (`ctx.bus.emit`).
|
|
11
|
+
- **Status segments and docks** are *pull* pipes (`ctx.bus.onPipe`): ashi asks for contributions
|
|
12
|
+
while it repaints and you append yours — ashi owns the layout.
|
|
13
|
+
- **Dialogs** are request/response calls (`await ctx.call(...)`) that resolve when the user answers.
|
|
14
|
+
|
|
15
|
+
## Setup
|
|
16
|
+
|
|
17
|
+
For typed events, import the augmentation once (types only — no runtime cost):
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import "@guanyilun/ashi/events";
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`ui:*` names are meant to work under any ashi-compatible frontend; `ashi:*` names are specific to
|
|
24
|
+
ashi's terminal UI. Before a `ctx.call(...)` that must return a value, feature-detect:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
if (ctx.list().includes("ui:select")) { /* … */ }
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Bus emits (`ui:notify`, the `*:invalidate` nudges) need no detection — they're no-ops when nothing
|
|
31
|
+
is listening.
|
|
32
|
+
|
|
33
|
+
## Timing
|
|
34
|
+
|
|
35
|
+
Extensions load before ashi mounts, so the surfaces aren't all live at `activate()` time:
|
|
36
|
+
|
|
37
|
+
- **Pull contributors (`status`, `dock`, any `onPipe`) can be registered any time** — ashi reads them
|
|
38
|
+
on its next repaint, so registering during `activate()` is fine; the first paint picks them up.
|
|
39
|
+
- **Imperative surfaces (notify, dialogs, editor) are ready once ashi has mounted.** From a command
|
|
40
|
+
or key handler they're always ready. To use one at startup, wait for the `ashi:ready` event:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
ctx.bus.on("ashi:ready", () => createUi(ctx).notify("loaded"));
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The helper degrades (`select`/`input` → `undefined`, `confirm` → `false`) if called before a frontend
|
|
47
|
+
answers, so a premature call can't throw; a premature `notify` is simply dropped.
|
|
48
|
+
|
|
49
|
+
## Post a notice
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
ctx.bus.emit("ui:notify", { message: "Saved.", level: "success" });
|
|
53
|
+
// level: "info" (default) | "warn" | "error" | "success"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Appends a themed line to the transcript.
|
|
57
|
+
|
|
58
|
+
## Add a status-bar segment
|
|
59
|
+
|
|
60
|
+
Contribute to the footer; ashi appends your segment and owns placement:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
ctx.bus.onPipe("ui:status", (p) => ({
|
|
64
|
+
segments: [...p.segments, { id: "build", text: "✓ build ok", color: "success" }],
|
|
65
|
+
}));
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
A segment is `{ id: string; text: string; color?: ThemeColor }`, where `ThemeColor` is a theme name
|
|
69
|
+
such as `"accent"`, `"success"`, `"warning"`, `"error"`, or `"muted"`. When your data changes
|
|
70
|
+
outside a repaint, ask ashi to re-pull:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
ctx.bus.emit("ui:status:invalidate", {});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Pin a widget above the input
|
|
77
|
+
|
|
78
|
+
Same pull model, but you build the view from the renderer's node factory (so you never import a
|
|
79
|
+
TUI library):
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
ctx.bus.onPipe("ashi:dock:above-input", (p) => {
|
|
83
|
+
const line = p.nodes.text({ paddingX: 1 });
|
|
84
|
+
line.setText("📌 2 todos remaining");
|
|
85
|
+
return { ...p, views: [...p.views, line.node] };
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// after a change:
|
|
89
|
+
ctx.bus.emit("ashi:dock:invalidate", {});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`p.nodes` offers `text`, `markdown`, `container`, `spacer`, and `image`. Return the payload
|
|
93
|
+
unchanged to contribute nothing — the dock takes zero space when empty.
|
|
94
|
+
|
|
95
|
+
## Ask the user
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
const fruit = await ctx.call("ui:select", {
|
|
99
|
+
title: "Pick a fruit",
|
|
100
|
+
items: [
|
|
101
|
+
{ value: "apple", label: "Apple", description: "crisp" },
|
|
102
|
+
{ value: "banana", label: "Banana" },
|
|
103
|
+
],
|
|
104
|
+
}); // → the chosen value, or undefined if cancelled
|
|
105
|
+
|
|
106
|
+
const ok = await ctx.call("ui:confirm", { title: "Delete it?" }); // → boolean
|
|
107
|
+
|
|
108
|
+
const name = await ctx.call("ui:input", {
|
|
109
|
+
title: "Name?", // hint shown above the input
|
|
110
|
+
prefill: "untitled", // optional starting text
|
|
111
|
+
}); // → the text, or undefined if cancelled (Esc)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Only one dialog (or built-in picker) is open at a time; a call made while one is open resolves
|
|
115
|
+
`undefined`.
|
|
116
|
+
|
|
117
|
+
## Read or seed the input
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
const draft = ctx.call("ui:editor:get-text") as string;
|
|
121
|
+
ctx.call("ui:editor:set-text", "/commit ");
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Typed helper
|
|
125
|
+
|
|
126
|
+
If you're willing to depend on `@guanyilun/ashi`, `createUi(ctx)` wraps everything above with
|
|
127
|
+
full types and no magic strings. Request/response surfaces also degrade on their own — `select`
|
|
128
|
+
and `input` resolve `undefined`, `confirm` resolves `false` — when no frontend answers:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
import { createUi } from "@guanyilun/ashi/ui";
|
|
132
|
+
|
|
133
|
+
const ui = createUi(ctx);
|
|
134
|
+
ui.notify("Saved.", "success");
|
|
135
|
+
const fruit = await ui.select({ title: "Pick", items: [{ value: "a", label: "Apple" }] });
|
|
136
|
+
const seg = ui.status(() => ({ id: "build", text: "✓ ok", color: "success" })); // seg.refresh() / seg.remove()
|
|
137
|
+
const widget = ui.dock((nodes) => {
|
|
138
|
+
const t = nodes.text({ paddingX: 1 });
|
|
139
|
+
t.setText("📌 note");
|
|
140
|
+
return t.node;
|
|
141
|
+
});
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The raw events and calls above carry no build-time dependency on ashi — reach for them if you
|
|
145
|
+
want a dependency-free extension. The helper is the same protocol with types and degradation
|
|
146
|
+
bolted on.
|
|
147
|
+
|
|
148
|
+
## Not yet available
|
|
149
|
+
|
|
150
|
+
Floating/overlay panels and fully custom interactive components aren't exposed — the renderer
|
|
151
|
+
contract has no free-placement layer yet. Use the dock, dialogs, and notices above.
|
|
152
|
+
|
|
153
|
+
## Working example
|
|
154
|
+
|
|
155
|
+
[`ashi-ui-demo.ts`](../../ashi-ui-demo.ts) exercises every surface through the typed helper. Load
|
|
156
|
+
it and try the commands:
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
ashi -e ashi-ui-demo
|
|
160
|
+
/ui-demo # select → confirm → input, then a notice
|
|
161
|
+
/ui-demo-bump # update the status segment
|
|
162
|
+
/ui-demo-dock # toggle the pinned widget
|
|
163
|
+
```
|
|
@@ -16,6 +16,14 @@
|
|
|
16
16
|
"./renderer": {
|
|
17
17
|
"types": "./dist/renderer.d.ts",
|
|
18
18
|
"import": "./dist/renderer.js"
|
|
19
|
+
},
|
|
20
|
+
"./events": {
|
|
21
|
+
"types": "./dist/events.d.ts",
|
|
22
|
+
"import": "./dist/events.js"
|
|
23
|
+
},
|
|
24
|
+
"./ui": {
|
|
25
|
+
"types": "./dist/ui.d.ts",
|
|
26
|
+
"import": "./dist/ui.js"
|
|
19
27
|
}
|
|
20
28
|
},
|
|
21
29
|
"files": [
|
|
@@ -60,7 +68,7 @@
|
|
|
60
68
|
},
|
|
61
69
|
"dependencies": {
|
|
62
70
|
"@earendil-works/pi-tui": "^0.74.0",
|
|
63
|
-
"agent-sh": "^0.14.
|
|
71
|
+
"agent-sh": "^0.14.11",
|
|
64
72
|
"chalk": "^5.5.0",
|
|
65
73
|
"cli-highlight": "^2.1.11"
|
|
66
74
|
},
|
|
@@ -2,6 +2,9 @@ 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
|
+
interface DiffEntry { diff: unknown; filePath: string }
|
|
6
|
+
export interface NestedDiff extends DiffEntry { name: string }
|
|
7
|
+
|
|
5
8
|
// liveEntryIds is parallel to the live messages array; null slots are synthetics (e.g. compaction summaries) with no entry.
|
|
6
9
|
export interface Capture {
|
|
7
10
|
flush(): Promise<void>;
|
|
@@ -14,21 +17,56 @@ export function registerCapture(
|
|
|
14
17
|
getStore: () => MultiSessionStore,
|
|
15
18
|
): Capture {
|
|
16
19
|
let liveEntryIds: (string | null)[] = [];
|
|
17
|
-
|
|
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
|
+
});
|
|
18
39
|
|
|
19
40
|
ctx.bus.on("agent:tool-completed", (e) => {
|
|
20
41
|
const id = e.toolCallId;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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;
|
|
24
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 });
|
|
25
57
|
});
|
|
26
58
|
|
|
27
59
|
const enrich = (m: AgentMessage): AgentMessage => {
|
|
28
60
|
if (m.role !== "tool" || !m.tool_call_id) return m;
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
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 };
|
|
32
70
|
};
|
|
33
71
|
|
|
34
72
|
const writeNewMessages = async (): Promise<void> => {
|
|
@@ -1,23 +1,11 @@
|
|
|
1
1
|
import type { ContainerView, MarkdownView, RenderNode, RenderNodes } from "../renderer.js";
|
|
2
2
|
|
|
3
|
-
export type
|
|
3
|
+
export type RenderBlock =
|
|
4
|
+
| { type: "text"; text: string }
|
|
5
|
+
| { type: "code-block"; language: string; code: string }
|
|
6
|
+
| { type: "image"; data: Buffer };
|
|
4
7
|
|
|
5
|
-
type
|
|
6
|
-
|
|
7
|
-
function segmentLatex(text: string): LatexSegment[] {
|
|
8
|
-
const segments: LatexSegment[] = [];
|
|
9
|
-
let i = 0;
|
|
10
|
-
while (i < text.length) {
|
|
11
|
-
const open = text.indexOf("$$", i);
|
|
12
|
-
if (open === -1) { segments.push({ type: "text", value: text.slice(i) }); break; }
|
|
13
|
-
const close = text.indexOf("$$", open + 2);
|
|
14
|
-
if (close === -1) { segments.push({ type: "text", value: text.slice(i) }); break; }
|
|
15
|
-
if (open > i) segments.push({ type: "text", value: text.slice(i, open) });
|
|
16
|
-
segments.push({ type: "latex", value: text.slice(open + 2, close).trim() });
|
|
17
|
-
i = close + 2;
|
|
18
|
-
}
|
|
19
|
-
return segments;
|
|
20
|
-
}
|
|
8
|
+
export type ContentTransform = (blocks: RenderBlock[]) => RenderBlock[];
|
|
21
9
|
|
|
22
10
|
export class AssistantMessage {
|
|
23
11
|
readonly node: RenderNode;
|
|
@@ -25,7 +13,7 @@ export class AssistantMessage {
|
|
|
25
13
|
private md: MarkdownView;
|
|
26
14
|
private buffer = "";
|
|
27
15
|
|
|
28
|
-
constructor(private nodes: RenderNodes, private
|
|
16
|
+
constructor(private nodes: RenderNodes, private transform: ContentTransform = (b) => b) {
|
|
29
17
|
this.container = nodes.container();
|
|
30
18
|
this.md = nodes.markdown({ paddingX: 1, bullet: true });
|
|
31
19
|
this.container.addChild(nodes.spacer(1));
|
|
@@ -46,37 +34,29 @@ export class AssistantMessage {
|
|
|
46
34
|
|
|
47
35
|
finalize(): void {
|
|
48
36
|
if (this.buffer === "") this.buffer = " ";
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
} else {
|
|
37
|
+
const blocks = this.transform([{ type: "text", text: this.buffer }]);
|
|
38
|
+
if (blocks.every((b) => b.type === "text")) {
|
|
52
39
|
this.md.setText(this.buffer);
|
|
40
|
+
return;
|
|
53
41
|
}
|
|
42
|
+
this.rebuild(blocks);
|
|
54
43
|
}
|
|
55
44
|
|
|
56
|
-
private
|
|
57
|
-
const segments = segmentLatex(this.buffer);
|
|
58
|
-
if (!segments.some((s) => s.type === "latex")) {
|
|
59
|
-
this.md.setText(this.buffer);
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
45
|
+
private rebuild(blocks: RenderBlock[]): void {
|
|
62
46
|
this.container.clear();
|
|
63
47
|
this.container.addChild(this.nodes.spacer(1));
|
|
64
|
-
for (const
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const m = this.nodes.markdown({ paddingX: 1 });
|
|
77
|
-
m.setText(`$$${seg.value}$$`);
|
|
78
|
-
this.container.addChild(m.node);
|
|
79
|
-
}
|
|
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);
|
|
80
60
|
}
|
|
81
61
|
}
|
|
82
62
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { RenderNode, RenderNodes } from "../renderer.js";
|
|
2
|
-
import { theme } from "../theme.js";
|
|
2
|
+
import { theme, type ThemeColor } from "../theme.js";
|
|
3
3
|
|
|
4
4
|
export class InfoLine {
|
|
5
5
|
readonly node: RenderNode;
|
|
@@ -18,3 +18,22 @@ export class ErrorLine {
|
|
|
18
18
|
this.node = t.node;
|
|
19
19
|
}
|
|
20
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
|
+
}
|
|
@@ -24,6 +24,7 @@ function headlessTerminal(): Terminal {
|
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
import "./events.js";
|
|
27
28
|
import { mountAshi } from "./frontend.js";
|
|
28
29
|
import { MultiSessionStore } from "./multi-session-store.js";
|
|
29
30
|
import { registerForkCommands, applyBranchMessages } from "./commands.js";
|
|
@@ -38,6 +39,18 @@ import { loadRendererPreference } from "./display-config.js";
|
|
|
38
39
|
import { applyOutputMode } from "./terminal-mode.js";
|
|
39
40
|
import * as os from "node:os";
|
|
40
41
|
import * as path from "node:path";
|
|
42
|
+
import { fileURLToPath } from "node:url";
|
|
43
|
+
|
|
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`;
|
|
41
54
|
|
|
42
55
|
function parseArgs(argv: string[]): AppConfig & { extensions?: string[]; continueLast: boolean; renderer?: string } {
|
|
43
56
|
let model: string | undefined;
|
|
@@ -167,7 +180,7 @@ async function main(): Promise<void> {
|
|
|
167
180
|
|
|
168
181
|
activateAgent(ctx);
|
|
169
182
|
activateShellContext(ctx);
|
|
170
|
-
await loadBuiltinExtensions(ctx
|
|
183
|
+
const builtinExtensions = await loadBuiltinExtensions(ctx);
|
|
171
184
|
|
|
172
185
|
const shell = new Shell({
|
|
173
186
|
bus: core.bus,
|
|
@@ -226,12 +239,15 @@ async function main(): Promise<void> {
|
|
|
226
239
|
registerRenderDefaults(ctx, renderer);
|
|
227
240
|
registerDefaultSchemaRenderers(ctx);
|
|
228
241
|
|
|
229
|
-
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
|
+
});
|
|
230
246
|
|
|
231
247
|
const handle = mountAshi(ctx, getStore, capture, renderer);
|
|
232
248
|
stopFrontend = handle.stop;
|
|
233
249
|
|
|
234
|
-
|
|
250
|
+
core.bus.emit("ashi:ready", {});
|
|
235
251
|
|
|
236
252
|
registerForkCommands(ctx, getStore, handle.openTreePicker, handle.rebuildChat, capture);
|
|
237
253
|
registerSessionCommands(ctx, getStore, capture, {
|
|
@@ -246,6 +262,12 @@ async function main(): Promise<void> {
|
|
|
246
262
|
applyBranchMessages(ctx, getStore, capture);
|
|
247
263
|
await handle.rebuildChat();
|
|
248
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
|
+
}
|
|
249
271
|
}
|
|
250
272
|
|
|
251
273
|
process.on("SIGTERM", cleanup);
|
|
@@ -25,7 +25,7 @@ export async function readClipboardImage(): Promise<CapturedImage | null> {
|
|
|
25
25
|
"-e", `set fp to open for access POSIX file ${JSON.stringify(tmp)} with write permission`,
|
|
26
26
|
"-e", "write png_data to fp",
|
|
27
27
|
"-e", "close access fp",
|
|
28
|
-
]);
|
|
28
|
+
], { timeout: 10_000 });
|
|
29
29
|
} catch {
|
|
30
30
|
return null;
|
|
31
31
|
}
|
|
@@ -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
|
+
}
|
|
@@ -31,6 +31,7 @@ interface AshiSettings extends Record<string, unknown> {
|
|
|
31
31
|
display?: Record<string, Partial<ToolEntryConfig>>;
|
|
32
32
|
groupMaxVisible?: number;
|
|
33
33
|
renderer?: string;
|
|
34
|
+
imageScale?: number;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
export function loadRendererPreference(): string | undefined {
|
|
@@ -45,6 +46,12 @@ export function loadGroupMaxVisible(): number {
|
|
|
45
46
|
return Math.floor(v);
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
export function loadImageScale(fallback: number): number {
|
|
50
|
+
const v = getExtensionSettings<AshiSettings>("ashi", {}).imageScale;
|
|
51
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v <= 0) return fallback;
|
|
52
|
+
return v;
|
|
53
|
+
}
|
|
54
|
+
|
|
48
55
|
function mergeEntry(base: ToolEntryConfig, patch?: Partial<ToolEntryConfig>): ToolEntryConfig {
|
|
49
56
|
if (!patch) return { ...base };
|
|
50
57
|
return {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { EventBus } from "agent-sh/event-bus";
|
|
2
|
+
import type { App, Renderer } from "./renderer.js";
|
|
3
|
+
|
|
4
|
+
export interface Dock {
|
|
5
|
+
refresh(): void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function createDock(app: App, renderer: Renderer, bus: EventBus): Dock {
|
|
9
|
+
const container = renderer.container();
|
|
10
|
+
let mounted = false;
|
|
11
|
+
|
|
12
|
+
const refresh = (): void => {
|
|
13
|
+
container.clear();
|
|
14
|
+
const { views } = bus.emitPipe("ashi:dock:above-input", { nodes: renderer, views: [] });
|
|
15
|
+
for (const view of views) container.addChild(view);
|
|
16
|
+
// Mount only when non-empty: an always-present footer child (even empty) defeats the
|
|
17
|
+
// footer slot's blank-line spacing above the input.
|
|
18
|
+
if (views.length > 0 && !mounted) {
|
|
19
|
+
app.footerSlot.addChild(container.node);
|
|
20
|
+
mounted = true;
|
|
21
|
+
} else if (views.length === 0 && mounted) {
|
|
22
|
+
app.footerSlot.removeChild(container.node);
|
|
23
|
+
mounted = false;
|
|
24
|
+
}
|
|
25
|
+
app.requestRender();
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
bus.on("ashi:dock:invalidate", refresh);
|
|
29
|
+
refresh();
|
|
30
|
+
return { refresh };
|
|
31
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// ui:* names are neutral by intent but declared HERE in ashi, not core, on purpose: a name
|
|
2
|
+
// graduates to core's BusEvents only when a second frontend outside ashi speaks it
|
|
3
|
+
// (see docs/ui-surface-protocol.md). ashi:* names carry TUI vocabulary and stay ashi-owned.
|
|
4
|
+
import type { RenderNode, RenderNodes } from "./renderer.js";
|
|
5
|
+
import type { StatusSegment } from "./status-footer.js";
|
|
6
|
+
|
|
7
|
+
declare module "agent-sh/event-bus" {
|
|
8
|
+
interface BusEvents {
|
|
9
|
+
"ui:notify": { message: string; level?: "info" | "warn" | "error" | "success" };
|
|
10
|
+
"ui:status": { segments: StatusSegment[] };
|
|
11
|
+
"ui:status:invalidate": Record<string, never>;
|
|
12
|
+
"ashi:dock:above-input": { nodes: RenderNodes; views: RenderNode[] };
|
|
13
|
+
"ashi:dock:invalidate": Record<string, never>;
|
|
14
|
+
"ashi:ready": Record<string, never>;
|
|
15
|
+
}
|
|
16
|
+
}
|