agent-sh 0.14.1 → 0.14.3
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 +1 -1
- package/dist/agent/agent-loop.js +42 -31
- package/dist/agent/conversation-state.d.ts +3 -2
- package/dist/agent/conversation-state.js +20 -3
- package/dist/agent/events.d.ts +2 -0
- package/dist/agent/host-types.d.ts +3 -0
- package/dist/agent/index.js +2 -1
- package/dist/agent/subagent.d.ts +1 -1
- package/dist/agent/subagent.js +5 -1
- package/dist/agent/tool-protocol.d.ts +2 -2
- package/dist/agent/tool-protocol.js +5 -4
- package/dist/agent/tools/glob.d.ts +1 -1
- package/dist/agent/tools/glob.js +4 -2
- package/dist/agent/tools/grep.d.ts +1 -1
- package/dist/agent/tools/grep.js +4 -2
- package/dist/agent/tools/ls.d.ts +1 -1
- package/dist/agent/tools/ls.js +4 -2
- package/dist/agent/tools/read-file.d.ts +1 -1
- package/dist/agent/tools/read-file.js +30 -2
- package/dist/agent/types.d.ts +11 -1
- package/dist/agent/types.js +6 -1
- package/dist/cli/index.js +0 -0
- package/dist/core/index.d.ts +1 -1
- package/dist/core/settings.d.ts +3 -0
- package/dist/core/settings.js +2 -2
- package/dist/shell/events.d.ts +2 -0
- package/dist/shell/index.d.ts +6 -0
- package/dist/shell/index.js +10 -10
- package/dist/shell/output-parser.d.ts +11 -22
- package/dist/shell/output-parser.js +16 -34
- package/dist/shell/shell-context.d.ts +3 -6
- package/dist/shell/shell-context.js +15 -7
- package/dist/shell/shell.d.ts +4 -0
- package/dist/shell/shell.js +18 -30
- package/dist/shell/strategies/types.d.ts +6 -0
- package/dist/shell/strategies/zsh.js +7 -0
- package/dist/shell/terminal.d.ts +33 -0
- package/dist/shell/terminal.js +62 -0
- package/examples/extensions/ash-scheme/index.ts +2170 -0
- package/examples/extensions/ash-scheme/package.json +11 -0
- package/examples/extensions/ash-scheme-render.ts +58 -0
- package/examples/extensions/ashi/README.md +36 -26
- package/examples/extensions/ashi/package.json +9 -1
- package/examples/extensions/ashi/src/capture.ts +1 -0
- package/examples/extensions/ashi/src/cli.ts +53 -11
- package/examples/extensions/ashi/src/commands.ts +2 -20
- package/examples/extensions/ashi/src/compaction.ts +25 -96
- package/examples/extensions/ashi/src/components.ts +64 -166
- package/examples/extensions/ashi/src/default-schema-renderers.ts +232 -0
- package/examples/extensions/ashi/src/display-config.ts +21 -22
- package/examples/extensions/ashi/src/frontend.ts +355 -118
- package/examples/extensions/ashi/src/hooks.ts +47 -63
- package/examples/extensions/ashi/src/multi-session-store.ts +44 -3
- package/examples/extensions/ashi/src/schema.ts +386 -0
- package/examples/extensions/ashi/src/session-store.ts +115 -17
- package/examples/extensions/ashi/src/shell-mode.ts +52 -0
- package/examples/extensions/ashi/src/status-footer.ts +41 -6
- package/examples/extensions/ashi/src/theme.ts +2 -1
- package/examples/extensions/ashi-compact-llm.ts +93 -0
- package/examples/extensions/claude-code-bridge/index.ts +2 -0
- package/examples/extensions/opencode-bridge/index.ts +3 -0
- package/examples/extensions/opencode-provider.ts +252 -0
- package/examples/extensions/pi-bridge/index.ts +1 -0
- package/package.json +16 -1
- package/examples/extensions/ashi/src/default-renderers.ts +0 -171
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
import type { Component } from "@earendil-works/pi-tui";
|
|
2
2
|
import type { ExtensionContext } from "agent-sh/types";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
ToolResultBody,
|
|
7
|
-
UserMessage,
|
|
8
|
-
} from "./components.js";
|
|
9
|
-
import { entryFor, loadToolDisplayConfig, type ToolResultMode } from "./display-config.js";
|
|
3
|
+
import { AssistantMessage, ThinkingBlock, UserMessage } from "./components.js";
|
|
4
|
+
import { loadDisplayResolver, type ToolResultMode } from "./display-config.js";
|
|
5
|
+
import { isRenderModel, mountCall, mountResult, type RenderModel } from "./schema.js";
|
|
10
6
|
|
|
11
7
|
export interface RenderState {
|
|
12
8
|
state: Record<string, unknown>;
|
|
@@ -14,41 +10,19 @@ export interface RenderState {
|
|
|
14
10
|
}
|
|
15
11
|
|
|
16
12
|
export interface UserMessageArgs extends RenderState { text: string }
|
|
17
|
-
|
|
18
13
|
export interface AssistantArgs extends RenderState { text: string }
|
|
19
|
-
|
|
20
14
|
export interface ThinkingArgs extends RenderState { text: string; hidden: boolean }
|
|
21
15
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
title: string;
|
|
26
|
-
kind?: string;
|
|
27
|
-
displayDetail?: string;
|
|
28
|
-
rawInput?: unknown;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export interface ToolResultArgs extends RenderState {
|
|
32
|
-
toolCallId: string;
|
|
33
|
-
name: string;
|
|
34
|
-
kind?: string;
|
|
35
|
-
rawInput?: unknown;
|
|
36
|
-
/** Resolved from ashi.display.{name} (or .default) in settings.json. */
|
|
37
|
-
mode: ToolResultMode;
|
|
38
|
-
previewLines: number;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Mutated by ashi when the tool completes. Renderers may ignore setStatus
|
|
42
|
-
* if they encode status differently (e.g. a sigil in the call line). */
|
|
16
|
+
/** The contract ashi's frontend mutates on the call-side component. Schema
|
|
17
|
+
* renderers satisfy this via SchemaCallComponent — extension authors don't
|
|
18
|
+
* implement it directly. */
|
|
43
19
|
export interface ToolCallView extends Component {
|
|
44
20
|
setStatus(opts: { exitCode: number | null; elapsedMs: number; summary?: string }): void;
|
|
21
|
+
toggleExpanded?(): void;
|
|
45
22
|
}
|
|
46
23
|
|
|
47
|
-
/**
|
|
48
|
-
*
|
|
49
|
-
* show diffs. The renderer is called on each terminal-width change so diffs
|
|
50
|
-
* reflow on resize. toggleExpanded flips the view's internal expansion state
|
|
51
|
-
* (Ctrl+O). */
|
|
24
|
+
/** Result-side counterpart. setDiffRenderer receives the width-aware diff
|
|
25
|
+
* closure produced by the edit/write tool at finalize. */
|
|
52
26
|
export interface ToolResultView extends Component {
|
|
53
27
|
appendChunk(chunk: string): void;
|
|
54
28
|
setDiffRenderer(fn: (width: number) => string[]): void;
|
|
@@ -56,12 +30,8 @@ export interface ToolResultView extends Component {
|
|
|
56
30
|
toggleExpanded(): void;
|
|
57
31
|
}
|
|
58
32
|
|
|
59
|
-
const
|
|
60
|
-
const RESULT_PREFIX = "ashi:render-tool-result:";
|
|
33
|
+
const SCHEMA_PREFIX = "ashi:render-tool:";
|
|
61
34
|
|
|
62
|
-
/** Register the default render-* handlers. Per-tool overrides are advised by
|
|
63
|
-
* name (e.g. `ashi:render-tool-call:bash`); unknown tools fall back to
|
|
64
|
-
* `:default`. */
|
|
65
35
|
export function registerRenderDefaults(ctx: ExtensionContext): void {
|
|
66
36
|
ctx.define("ashi:render-user-message", (args: UserMessageArgs): Component => {
|
|
67
37
|
return new UserMessage(args.text);
|
|
@@ -85,54 +55,68 @@ export function registerRenderDefaults(ctx: ExtensionContext): void {
|
|
|
85
55
|
tb.setHidden(args.hidden);
|
|
86
56
|
return tb;
|
|
87
57
|
});
|
|
58
|
+
}
|
|
88
59
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
60
|
+
export interface ToolCallResolveArgs {
|
|
61
|
+
toolCallId: string;
|
|
62
|
+
name: string;
|
|
63
|
+
title: string;
|
|
64
|
+
kind?: string;
|
|
65
|
+
displayDetail?: string;
|
|
66
|
+
rawInput?: unknown;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ToolResultResolveArgs {
|
|
70
|
+
toolCallId: string;
|
|
71
|
+
name: string;
|
|
72
|
+
kind?: string;
|
|
73
|
+
rawInput?: unknown;
|
|
92
74
|
}
|
|
93
75
|
|
|
94
76
|
export interface ToolHookResolver {
|
|
95
|
-
call: (args:
|
|
96
|
-
result: (args:
|
|
77
|
+
call: (args: ToolCallResolveArgs) => ToolCallView;
|
|
78
|
+
result: (args: ToolResultResolveArgs) => ToolResultView;
|
|
97
79
|
modeFor: (name: string) => { mode: ToolResultMode; previewLines: number };
|
|
98
80
|
}
|
|
99
81
|
|
|
100
|
-
/** Resolves
|
|
101
|
-
*
|
|
102
|
-
*
|
|
82
|
+
/** Resolves a tool name to a schema RenderModel — :{name} first, then :default.
|
|
83
|
+
* Cache the registered-handler set; callers can `refresh()` after extensions
|
|
84
|
+
* register new tool-specific renderers. */
|
|
103
85
|
export function createToolHookResolver(
|
|
104
86
|
ctx: ExtensionContext,
|
|
105
|
-
renderState: () => RenderState,
|
|
106
87
|
): ToolHookResolver & { refresh: () => void } {
|
|
107
|
-
const
|
|
88
|
+
const resolver = loadDisplayResolver();
|
|
108
89
|
let registered = new Set(ctx.list());
|
|
109
90
|
|
|
110
|
-
const
|
|
111
|
-
const
|
|
112
|
-
|
|
91
|
+
const schemaModel = (name: string): RenderModel<unknown> => {
|
|
92
|
+
for (const candidate of [`${SCHEMA_PREFIX}${name}`, `${SCHEMA_PREFIX}default`]) {
|
|
93
|
+
if (!registered.has(candidate)) continue;
|
|
94
|
+
const v = ctx.call(candidate, {}) as unknown;
|
|
95
|
+
if (isRenderModel(v)) return v;
|
|
96
|
+
}
|
|
97
|
+
throw new Error(`no render model for tool "${name}" and no ${SCHEMA_PREFIX}default registered`);
|
|
113
98
|
};
|
|
114
99
|
|
|
100
|
+
// SchemaResultComponent.render(width) corrects env.width on the first frame.
|
|
101
|
+
const initialWidth = (): number => process.stdout.columns ?? 80;
|
|
102
|
+
|
|
115
103
|
return {
|
|
116
104
|
refresh(): void {
|
|
117
105
|
registered = new Set(ctx.list());
|
|
118
106
|
},
|
|
119
107
|
modeFor(name: string) {
|
|
120
|
-
const e =
|
|
108
|
+
const e = resolver.resolve(name, schemaModel(name).display);
|
|
121
109
|
return { mode: e.result, previewLines: e.previewLines };
|
|
122
110
|
},
|
|
123
111
|
call(args) {
|
|
124
|
-
const
|
|
125
|
-
return
|
|
112
|
+
const { mode, previewLines } = this.modeFor(args.name);
|
|
113
|
+
return mountCall(schemaModel(args.name), args,
|
|
114
|
+
{ width: initialWidth(), mode, previewLines }) as ToolCallView;
|
|
126
115
|
},
|
|
127
116
|
result(args) {
|
|
128
117
|
const { mode, previewLines } = this.modeFor(args.name);
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
...renderState(),
|
|
132
|
-
...args,
|
|
133
|
-
mode,
|
|
134
|
-
previewLines,
|
|
135
|
-
}) as ToolResultView;
|
|
118
|
+
return mountResult(schemaModel(args.name), { ...args, title: args.name },
|
|
119
|
+
{ width: initialWidth(), mode, previewLines }) as ToolResultView;
|
|
136
120
|
},
|
|
137
121
|
};
|
|
138
122
|
}
|
|
@@ -13,21 +13,62 @@ export interface SessionInfo {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
/** Many sessions per cwd. Each is one .jsonl file under `dir/`. Constructor
|
|
16
|
-
* always opens a fresh session
|
|
17
|
-
*
|
|
16
|
+
* always opens a fresh session unless `opts.resumeSessionId` is given and
|
|
17
|
+
* points to an existing session file. /resume callers can `openSession(id)`
|
|
18
|
+
* to swap the current store to a past session file. */
|
|
18
19
|
export class MultiSessionStore {
|
|
19
20
|
private dir: string;
|
|
20
21
|
private cwd: string;
|
|
21
22
|
private currentStore: SessionStore;
|
|
22
23
|
|
|
23
|
-
constructor(dir: string, cwd: string) {
|
|
24
|
+
constructor(dir: string, cwd: string, opts?: { resumeSessionId?: string }) {
|
|
24
25
|
this.dir = dir;
|
|
25
26
|
this.cwd = cwd;
|
|
26
27
|
fs.mkdirSync(dir, { recursive: true });
|
|
27
28
|
this.migrateLegacy();
|
|
29
|
+
if (opts?.resumeSessionId) {
|
|
30
|
+
const filePath = this.sessionFile(opts.resumeSessionId);
|
|
31
|
+
if (fs.existsSync(filePath)) {
|
|
32
|
+
this.currentStore = new SessionStore(filePath);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
28
36
|
this.currentStore = this.createFreshSession();
|
|
29
37
|
}
|
|
30
38
|
|
|
39
|
+
markLastSession(): void {
|
|
40
|
+
const lastFile = path.join(this.dir, ".last-session");
|
|
41
|
+
fs.writeFileSync(lastFile, this.currentStore.id);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
static readLastSessionId(dir: string, opts?: { fallbackToLatest?: boolean }): string | undefined {
|
|
45
|
+
const lastFile = path.join(dir, ".last-session");
|
|
46
|
+
try {
|
|
47
|
+
const id = fs.readFileSync(lastFile, "utf-8").trim();
|
|
48
|
+
if (id && fs.existsSync(path.join(dir, `${id}.jsonl`))) return id;
|
|
49
|
+
} catch { /* no .last-session file yet */ }
|
|
50
|
+
|
|
51
|
+
if (opts?.fallbackToLatest) {
|
|
52
|
+
let best: { id: string; createdAt: number } | undefined;
|
|
53
|
+
let names: string[];
|
|
54
|
+
try { names = fs.readdirSync(dir); } catch { return undefined; }
|
|
55
|
+
for (const name of names) {
|
|
56
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
57
|
+
const id = name.slice(0, -".jsonl".length);
|
|
58
|
+
try {
|
|
59
|
+
const raw = fs.readFileSync(path.join(dir, `${id}.jsonl.meta`), "utf-8");
|
|
60
|
+
const meta = JSON.parse(raw) as { createdAt?: number };
|
|
61
|
+
if (typeof meta.createdAt === "number" && (!best || meta.createdAt > best.createdAt)) {
|
|
62
|
+
best = { id, createdAt: meta.createdAt };
|
|
63
|
+
}
|
|
64
|
+
} catch { /* skip unreadable meta */ }
|
|
65
|
+
}
|
|
66
|
+
if (best) return best.id;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
31
72
|
/** One-time import from the previous storage format (sessions stored as
|
|
32
73
|
* directories with tree.jsonl + snapshots/). Each old session is replayed
|
|
33
74
|
* from its most recent snapshot into a new flat `.jsonl` file, then the
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// Declarative render schema for tool-call hooks. External renderers register
|
|
2
|
+
// `ctx.define("ashi:render-tool:<name>", () => ({ initial, reducers, view }))`.
|
|
3
|
+
|
|
4
|
+
import { Container, Spacer, Text, visibleWidth } from "@earendil-works/pi-tui";
|
|
5
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
6
|
+
import type { ThemeColor } from "./theme.js";
|
|
7
|
+
import type { ToolEntryConfig } from "./display-config.js";
|
|
8
|
+
|
|
9
|
+
export type { ToolEntryConfig, ToolResultMode } from "./display-config.js";
|
|
10
|
+
|
|
11
|
+
export type Color = ThemeColor;
|
|
12
|
+
|
|
13
|
+
export interface StyleHint {
|
|
14
|
+
color?: Color;
|
|
15
|
+
bold?: boolean;
|
|
16
|
+
dim?: boolean;
|
|
17
|
+
italic?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type Segment = string | { text: string; style?: StyleHint; highlight?: string };
|
|
21
|
+
|
|
22
|
+
export type Body =
|
|
23
|
+
| { kind: "text"; segments: Segment[] }
|
|
24
|
+
| { kind: "code"; lang?: string; text: string }
|
|
25
|
+
/** Width-aware renderer is supplied via setDiffRenderer; view() opts in by
|
|
26
|
+
* returning { kind: "diff" } and gating on hasDiff in state. */
|
|
27
|
+
| { kind: "diff" }
|
|
28
|
+
| { kind: "stream"; text: string }
|
|
29
|
+
| { kind: "lines"; lines: Segment[][] }
|
|
30
|
+
| { kind: "compound"; parts: Body[] };
|
|
31
|
+
|
|
32
|
+
export interface DisplayStatus {
|
|
33
|
+
exitCode: number | null;
|
|
34
|
+
elapsedMs: number;
|
|
35
|
+
summary?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Renderers pick a category; ashi picks the glyph. Falls back to generic. */
|
|
39
|
+
export type TitleIcon = "read" | "search" | "edit" | "shell" | "generic" | "scheme";
|
|
40
|
+
|
|
41
|
+
export interface ToolDisplay {
|
|
42
|
+
titleIcon?: TitleIcon;
|
|
43
|
+
title: Segment[];
|
|
44
|
+
/** Right-aligned on the title line; framework handles padding and reserves
|
|
45
|
+
* space for the status suffix so renderers don't compute widths. */
|
|
46
|
+
titleRight?: Segment[];
|
|
47
|
+
status?: DisplayStatus;
|
|
48
|
+
body?: Body;
|
|
49
|
+
expandable?: boolean;
|
|
50
|
+
defaultExpanded?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `mode` and `previewLines` come from ashi.display.{name} (display-config.ts). */
|
|
54
|
+
export interface Env {
|
|
55
|
+
width: number;
|
|
56
|
+
expanded: boolean;
|
|
57
|
+
finalized: boolean;
|
|
58
|
+
mode: "preview" | "summary" | "hidden";
|
|
59
|
+
previewLines: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type Reducer<S, P = unknown> = (state: S, payload: P) => S;
|
|
63
|
+
|
|
64
|
+
/** Framework tracks `output`, `status`, `hasDiff` — renderers don't wire
|
|
65
|
+
* `chunk` / `status` / `diff` reducers themselves. */
|
|
66
|
+
export type ViewState<S> = S & {
|
|
67
|
+
output: string;
|
|
68
|
+
status?: DisplayStatus;
|
|
69
|
+
hasDiff: boolean;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export interface RenderInitArgs {
|
|
73
|
+
rawInput?: unknown;
|
|
74
|
+
name: string;
|
|
75
|
+
title: string;
|
|
76
|
+
kind?: string;
|
|
77
|
+
displayDetail?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface RenderModel<S = Record<string, never>> {
|
|
81
|
+
initial: (args: RenderInitArgs) => S;
|
|
82
|
+
/** Only for tool-specific state transitions; `status`/`chunk` are framework-tracked. */
|
|
83
|
+
reducers?: Record<string, Reducer<ViewState<S>, never>>;
|
|
84
|
+
view: (state: ViewState<S>, env: Env) => ToolDisplay;
|
|
85
|
+
display?: Partial<ToolEntryConfig>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function isRenderModel(v: unknown): v is RenderModel<unknown> {
|
|
89
|
+
if (!v || typeof v !== "object") return false;
|
|
90
|
+
const o = v as Record<string, unknown>;
|
|
91
|
+
return typeof o.initial === "function" && typeof o.view === "function";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Call-side and result-side components share one state cell, so chunks from
|
|
95
|
+
// the result side can repaint the call line (e.g. for in-title progress).
|
|
96
|
+
|
|
97
|
+
import { theme } from "./theme.js";
|
|
98
|
+
|
|
99
|
+
interface DiffSlot {
|
|
100
|
+
fn?: (width: number) => string[];
|
|
101
|
+
lastWidth: number;
|
|
102
|
+
cached: string[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface SharedCell<S> {
|
|
106
|
+
state: S;
|
|
107
|
+
env: Env;
|
|
108
|
+
diff: DiffSlot;
|
|
109
|
+
callView?: SchemaCallComponent;
|
|
110
|
+
resultView?: SchemaResultComponent;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface RenderHandle<S> {
|
|
114
|
+
cell: SharedCell<S>;
|
|
115
|
+
model: RenderModel<S>;
|
|
116
|
+
toolCallId: string;
|
|
117
|
+
dispatch: (action: string, payload?: unknown) => void;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Lets the result-side mount find the call-side cell. Cleared on finalize. */
|
|
121
|
+
const HANDLES = new Map<string, RenderHandle<unknown>>();
|
|
122
|
+
|
|
123
|
+
export interface MountArgs {
|
|
124
|
+
toolCallId: string;
|
|
125
|
+
name: string;
|
|
126
|
+
title: string;
|
|
127
|
+
kind?: string;
|
|
128
|
+
displayDetail?: string;
|
|
129
|
+
rawInput?: unknown;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function handleFor<S>(
|
|
133
|
+
args: MountArgs,
|
|
134
|
+
model: RenderModel<S>,
|
|
135
|
+
envInit: { width: number; mode: Env["mode"]; previewLines: number },
|
|
136
|
+
): RenderHandle<S> {
|
|
137
|
+
const existing = HANDLES.get(args.toolCallId) as RenderHandle<S> | undefined;
|
|
138
|
+
if (existing) return existing;
|
|
139
|
+
const userInitial = model.initial({
|
|
140
|
+
rawInput: args.rawInput,
|
|
141
|
+
name: args.name,
|
|
142
|
+
title: args.title,
|
|
143
|
+
kind: args.kind,
|
|
144
|
+
displayDetail: args.displayDetail,
|
|
145
|
+
});
|
|
146
|
+
const cell: SharedCell<ViewState<S>> = {
|
|
147
|
+
state: { ...(userInitial as object), output: "", hasDiff: false } as ViewState<S>,
|
|
148
|
+
env: { ...envInit, expanded: false, finalized: false },
|
|
149
|
+
diff: { lastWidth: -1, cached: [] },
|
|
150
|
+
};
|
|
151
|
+
const reducers = model.reducers ?? {};
|
|
152
|
+
const handle: RenderHandle<ViewState<S>> = {
|
|
153
|
+
cell,
|
|
154
|
+
model: model as unknown as RenderModel<ViewState<S>>,
|
|
155
|
+
toolCallId: args.toolCallId,
|
|
156
|
+
dispatch(action, payload) {
|
|
157
|
+
if (action === "status") {
|
|
158
|
+
cell.state = { ...cell.state, status: payload as DisplayStatus };
|
|
159
|
+
} else if (action === "chunk") {
|
|
160
|
+
cell.state = { ...cell.state, output: cell.state.output + (payload as string) };
|
|
161
|
+
} else if (action === "diff") {
|
|
162
|
+
cell.diff = { fn: payload as (w: number) => string[], lastWidth: -1, cached: [] };
|
|
163
|
+
cell.state = { ...cell.state, hasDiff: true };
|
|
164
|
+
} else {
|
|
165
|
+
const reducer = reducers[action];
|
|
166
|
+
if (!reducer) return;
|
|
167
|
+
cell.state = (reducer as Reducer<ViewState<S>, unknown>)(cell.state, payload);
|
|
168
|
+
}
|
|
169
|
+
cell.callView?.repaint();
|
|
170
|
+
cell.resultView?.repaint();
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
HANDLES.set(args.toolCallId, handle as unknown as RenderHandle<unknown>);
|
|
174
|
+
return handle as unknown as RenderHandle<S>;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Sole place that knows about theme colors + highlighting; renderers stay pure-data.
|
|
178
|
+
|
|
179
|
+
import { highlight, supportsLanguage } from "cli-highlight";
|
|
180
|
+
|
|
181
|
+
function styleSegment(seg: Segment): string {
|
|
182
|
+
if (typeof seg === "string") return seg;
|
|
183
|
+
let text = seg.text;
|
|
184
|
+
if (seg.highlight && supportsLanguage(seg.highlight)) {
|
|
185
|
+
try { text = highlight(text, { language: seg.highlight, ignoreIllegals: true }); }
|
|
186
|
+
catch { /* fall through */ }
|
|
187
|
+
}
|
|
188
|
+
const s = seg.style;
|
|
189
|
+
if (!s) return text;
|
|
190
|
+
if (s.color) text = theme.fg(s.color, text);
|
|
191
|
+
if (s.bold) text = theme.bold(text);
|
|
192
|
+
if (s.italic) text = theme.italic(text);
|
|
193
|
+
if (s.dim) text = theme.fg("dim", text);
|
|
194
|
+
return text;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function segmentsToString(segs: Segment[]): string {
|
|
198
|
+
return segs.map(styleSegment).join("");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const TITLE_ICON_GLYPH: Record<TitleIcon, string> = {
|
|
202
|
+
read: "◆", search: "⌕", edit: "✎", shell: "$", scheme: "λ", generic: "⚙",
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
function iconString(icon?: TitleIcon): string {
|
|
206
|
+
if (!icon) return "";
|
|
207
|
+
return `${theme.fg("warning", TITLE_ICON_GLYPH[icon])} `;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function fmtElapsed(ms: number): string {
|
|
211
|
+
if (ms < 1000) return `${ms}ms`;
|
|
212
|
+
if (ms < 10_000) return `${(ms / 1000).toFixed(2)}s`;
|
|
213
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
214
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
215
|
+
return `${Math.floor(totalSeconds / 60)}m ${totalSeconds % 60}s`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function statusSuffix(s?: DisplayStatus): string {
|
|
219
|
+
if (!s) return ` ${theme.fg("muted", "…")}`;
|
|
220
|
+
const ok = s.exitCode === null || s.exitCode === 0;
|
|
221
|
+
const mark = ok ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
|
222
|
+
const elapsed = s.elapsedMs > 0 ? ` ${theme.fg("muted", fmtElapsed(s.elapsedMs))}` : "";
|
|
223
|
+
const sum = s.summary ? ` ${theme.fg("muted", s.summary)}` : "";
|
|
224
|
+
return ` ${mark}${elapsed}${sum}`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function renderBody(body: Body, env: Env, diff: DiffSlot, exitCode?: number | null): string {
|
|
228
|
+
switch (body.kind) {
|
|
229
|
+
case "text":
|
|
230
|
+
return segmentsToString(body.segments);
|
|
231
|
+
case "code": {
|
|
232
|
+
if (body.lang && supportsLanguage(body.lang)) {
|
|
233
|
+
try { return highlight(body.text, { language: body.lang, ignoreIllegals: true }); }
|
|
234
|
+
catch { /* fall through */ }
|
|
235
|
+
}
|
|
236
|
+
return body.text;
|
|
237
|
+
}
|
|
238
|
+
case "stream":
|
|
239
|
+
return renderStream(body.text, env, exitCode);
|
|
240
|
+
case "lines":
|
|
241
|
+
return body.lines.map(segmentsToString).join("\n");
|
|
242
|
+
case "diff": {
|
|
243
|
+
if (!diff.fn) return "";
|
|
244
|
+
if (diff.lastWidth !== env.width) {
|
|
245
|
+
diff.cached = diff.fn(env.width);
|
|
246
|
+
diff.lastWidth = env.width;
|
|
247
|
+
}
|
|
248
|
+
return diff.cached.join("\n");
|
|
249
|
+
}
|
|
250
|
+
case "compound":
|
|
251
|
+
return body.parts.map((p) => renderBody(p, env, diff, exitCode)).join("\n\n");
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Host-wide preview/summary/hidden policy inherited by every kind:"stream" body.
|
|
256
|
+
function renderStream(buffer: string, env: Env, exitCode: number | null | undefined): string {
|
|
257
|
+
const display = buffer.replace(/\n+$/, "");
|
|
258
|
+
if (env.expanded) return theme.fg("toolOutput", display);
|
|
259
|
+
if (env.mode === "hidden") {
|
|
260
|
+
if (!env.finalized) return "";
|
|
261
|
+
return lineCountHint(buffer, exitCode);
|
|
262
|
+
}
|
|
263
|
+
if (env.mode === "summary") {
|
|
264
|
+
if (!env.finalized) {
|
|
265
|
+
const tail = display.split("\n").slice(-2).join("\n");
|
|
266
|
+
return theme.fg("muted", tail);
|
|
267
|
+
}
|
|
268
|
+
return lineCountHint(buffer, exitCode);
|
|
269
|
+
}
|
|
270
|
+
if (!display) return "";
|
|
271
|
+
const lines = display.split("\n");
|
|
272
|
+
const trimmed = lines.slice(-env.previewLines).join("\n");
|
|
273
|
+
const remaining = Math.max(0, lines.length - env.previewLines);
|
|
274
|
+
const overflow = remaining > 0
|
|
275
|
+
? `\n${theme.fg("muted", `... (${remaining} more ${remaining === 1 ? "line" : "lines"})`)}`
|
|
276
|
+
: "";
|
|
277
|
+
return `${theme.fg("toolOutput", trimmed)}${overflow}`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function lineCountHint(buffer: string, exitCode: number | null | undefined): string {
|
|
281
|
+
const lines = buffer.split("\n").filter((l) => l.length > 0);
|
|
282
|
+
const label = lines.length === 1 ? "1 line" : `${lines.length} lines`;
|
|
283
|
+
const ok = exitCode === null || exitCode === 0;
|
|
284
|
+
const arrow = ok ? theme.fg("muted", "↳ ") : theme.fg("error", "↳ ");
|
|
285
|
+
return `${arrow}${theme.fg("muted", label)}`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Components that satisfy the legacy ToolCallView / ToolResultView contracts.
|
|
289
|
+
|
|
290
|
+
class SchemaCallComponent extends Container {
|
|
291
|
+
private line: Text;
|
|
292
|
+
constructor(private handle: RenderHandle<unknown>) {
|
|
293
|
+
super();
|
|
294
|
+
this.line = new Text("", 1, 0);
|
|
295
|
+
this.addChild(new Spacer(1));
|
|
296
|
+
this.addChild(this.line);
|
|
297
|
+
handle.cell.callView = this;
|
|
298
|
+
this.repaint();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
setStatus(opts: { exitCode: number | null; elapsedMs: number; summary?: string }): void {
|
|
302
|
+
this.handle.dispatch("status", opts);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
repaint(): void {
|
|
306
|
+
const display = this.handle.model.view(this.handle.cell.state as ViewState<unknown>, this.handle.cell.env);
|
|
307
|
+
const icon = iconString(display.titleIcon);
|
|
308
|
+
const title = segmentsToString(display.title);
|
|
309
|
+
const status = statusSuffix(display.status);
|
|
310
|
+
if (display.titleRight && display.titleRight.length > 0) {
|
|
311
|
+
const right = segmentsToString(display.titleRight);
|
|
312
|
+
// env.width − 2 accounts for Text's paddingX=1 on each side.
|
|
313
|
+
const used = visibleWidth(icon) + visibleWidth(title) + visibleWidth(status) + visibleWidth(right);
|
|
314
|
+
const pad = " ".repeat(Math.max(2, this.handle.cell.env.width - 2 - used));
|
|
315
|
+
this.line.setText(`${icon}${title}${status}${pad}${right}`);
|
|
316
|
+
} else {
|
|
317
|
+
this.line.setText(`${icon}${title}${status}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
class SchemaResultComponent extends Container {
|
|
323
|
+
private body: Text;
|
|
324
|
+
constructor(private handle: RenderHandle<unknown>) {
|
|
325
|
+
super();
|
|
326
|
+
this.body = new Text("", 0, 0);
|
|
327
|
+
this.addChild(this.body);
|
|
328
|
+
handle.cell.resultView = this;
|
|
329
|
+
this.repaint();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
appendChunk(chunk: string): void { this.handle.dispatch("chunk", chunk); }
|
|
333
|
+
setDiffRenderer(fn: (width: number) => string[]): void { this.handle.dispatch("diff", fn); }
|
|
334
|
+
finalize(opts: { exitCode: number | null; summary?: string }): void {
|
|
335
|
+
this.handle.cell.env = { ...this.handle.cell.env, finalized: true };
|
|
336
|
+
this.handle.dispatch("status", { ...opts, elapsedMs: 0 });
|
|
337
|
+
HANDLES.delete(this.handle.toolCallId);
|
|
338
|
+
}
|
|
339
|
+
toggleExpanded(): void {
|
|
340
|
+
this.handle.cell.env = { ...this.handle.cell.env, expanded: !this.handle.cell.env.expanded };
|
|
341
|
+
this.repaint();
|
|
342
|
+
this.handle.cell.callView?.repaint();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
override render(width: number): string[] {
|
|
346
|
+
if (this.handle.cell.env.width !== width) {
|
|
347
|
+
this.handle.cell.env = { ...this.handle.cell.env, width };
|
|
348
|
+
this.repaint();
|
|
349
|
+
}
|
|
350
|
+
return super.render(width);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
repaint(): void {
|
|
354
|
+
const env = this.handle.cell.env;
|
|
355
|
+
const display = this.handle.model.view(this.handle.cell.state as ViewState<unknown>, env);
|
|
356
|
+
if (!display.body) { this.body.setText(""); return; }
|
|
357
|
+
// stream embeds the preview/summary/hidden policy; diff shows in preview
|
|
358
|
+
// or when expanded; other kinds show only when expanded/defaultExpanded.
|
|
359
|
+
if (display.body.kind === "diff" && !env.expanded && env.mode !== "preview") {
|
|
360
|
+
this.body.setText("");
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const policied = display.body.kind === "stream" || display.body.kind === "diff";
|
|
364
|
+
if (!policied && !env.expanded && !display.defaultExpanded) {
|
|
365
|
+
this.body.setText("");
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
this.body.setText(renderBody(display.body, env, this.handle.cell.diff, display.status?.exitCode));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export interface MountEnv {
|
|
373
|
+
width: number;
|
|
374
|
+
mode: Env["mode"];
|
|
375
|
+
previewLines: number;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function mountCall<S>(model: RenderModel<S>, args: MountArgs, env: MountEnv): Component {
|
|
379
|
+
const handle = handleFor(args, model, env);
|
|
380
|
+
return new SchemaCallComponent(handle as RenderHandle<unknown>);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function mountResult<S>(model: RenderModel<S>, args: MountArgs, env: MountEnv): Component {
|
|
384
|
+
const handle = handleFor(args, model, env);
|
|
385
|
+
return new SchemaResultComponent(handle as RenderHandle<unknown>);
|
|
386
|
+
}
|