agent-sh 0.14.2 → 0.14.4

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.
@@ -1,14 +1,7 @@
1
- // Declarative render schema for tool-call hooks.
2
- //
3
- // External renderers register one hook per tool:
4
- // ctx.define("ashi:render-tool:scheme", () => ({ initial, reducers, view }))
5
- //
6
- // The view function is pure: `view(state, env)` returns a ToolDisplay describing
7
- // title + status + body. Ashi owns the pi-tui mapping, theming, streaming
8
- // buffer policy, diff reflow on resize, expand/collapse — everything that used
9
- // to leak into renderer subclasses.
10
-
11
- import { Container, Spacer, Text } from "@earendil-works/pi-tui";
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";
12
5
  import type { Component } from "@earendil-works/pi-tui";
13
6
  import type { ThemeColor } from "./theme.js";
14
7
  import type { ToolEntryConfig } from "./display-config.js";
@@ -29,9 +22,8 @@ export type Segment = string | { text: string; style?: StyleHint; highlight?: st
29
22
  export type Body =
30
23
  | { kind: "text"; segments: Segment[] }
31
24
  | { kind: "code"; lang?: string; text: string }
32
- /** Diff body framework supplies the width-aware renderer via setDiffRenderer
33
- * (called from frontend.ts when the edit/write tool finalizes). Renderers
34
- * opt in by returning { kind: "diff" } and reading hasDiff from state. */
25
+ /** Width-aware renderer is supplied via setDiffRenderer; view() opts in by
26
+ * returning { kind: "diff" } and gating on hasDiff in state. */
35
27
  | { kind: "diff" }
36
28
  | { kind: "stream"; text: string }
37
29
  | { kind: "lines"; lines: Segment[][] }
@@ -43,22 +35,22 @@ export interface DisplayStatus {
43
35
  summary?: string;
44
36
  }
45
37
 
46
- /** Built-in icon set ashi knows how to theme. Renderers pick a category;
47
- * ashi picks the glyph. Falls back to the generic gear if absent. */
38
+ /** Renderers pick a category; ashi picks the glyph. Falls back to generic. */
48
39
  export type TitleIcon = "read" | "search" | "edit" | "shell" | "generic" | "scheme";
49
40
 
50
41
  export interface ToolDisplay {
51
42
  titleIcon?: TitleIcon;
52
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[];
53
47
  status?: DisplayStatus;
54
48
  body?: Body;
55
49
  expandable?: boolean;
56
50
  defaultExpanded?: boolean;
57
51
  }
58
52
 
59
- /** What the host tells the view about the rendering environment. Pure:
60
- * changes here trigger a re-invocation of view(). `mode` and `previewLines`
61
- * come from ashi.display.{name} — see display-config.ts. */
53
+ /** `mode` and `previewLines` come from ashi.display.{name} (display-config.ts). */
62
54
  export interface Env {
63
55
  width: number;
64
56
  expanded: boolean;
@@ -69,9 +61,8 @@ export interface Env {
69
61
 
70
62
  export type Reducer<S, P = unknown> = (state: S, payload: P) => S;
71
63
 
72
- /** State as seen by view()the user's S plus framework-tracked output/status.
73
- * Renderers never need to wire `chunk` / `status` / `diff` reducers themselves.
74
- * hasDiff is true once setDiffRenderer has been called (edit/write tools). */
64
+ /** Framework tracks `output`, `status`, `hasDiff`renderers don't wire
65
+ * `chunk` / `status` / `diff` reducers themselves. */
75
66
  export type ViewState<S> = S & {
76
67
  output: string;
77
68
  status?: DisplayStatus;
@@ -88,8 +79,7 @@ export interface RenderInitArgs {
88
79
 
89
80
  export interface RenderModel<S = Record<string, never>> {
90
81
  initial: (args: RenderInitArgs) => S;
91
- /** Optional. `status` and `chunk` are tracked by the framework — declare
92
- * reducers here only for tool-specific state transitions. */
82
+ /** Only for tool-specific state transitions; `status`/`chunk` are framework-tracked. */
93
83
  reducers?: Record<string, Reducer<ViewState<S>, never>>;
94
84
  view: (state: ViewState<S>, env: Env) => ToolDisplay;
95
85
  display?: Partial<ToolEntryConfig>;
@@ -101,12 +91,8 @@ export function isRenderModel(v: unknown): v is RenderModel<unknown> {
101
91
  return typeof o.initial === "function" && typeof o.view === "function";
102
92
  }
103
93
 
104
- // ---------------------------------------------------------------------------
105
- // Adapter: model paired Components for call-side and result-side hooks.
106
- //
107
- // Both Components share a single state cell so that a `chunk` dispatch from
108
- // the result side repaints the call line too (e.g. for renderers that show
109
- // progress in the title).
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).
110
96
 
111
97
  import { theme } from "./theme.js";
112
98
 
@@ -131,10 +117,7 @@ interface RenderHandle<S> {
131
117
  dispatch: (action: string, payload?: unknown) => void;
132
118
  }
133
119
 
134
- /** Per-toolCallId handle registry sole purpose is letting the result-side
135
- * mount find the call-side cell so they can share state. Once the result
136
- * component is mounted, both views hold their own handle reference and the
137
- * map entry is dead weight; cleared on finalize. */
120
+ /** Lets the result-side mount find the call-side cell. Cleared on finalize. */
138
121
  const HANDLES = new Map<string, RenderHandle<unknown>>();
139
122
 
140
123
  export interface MountArgs {
@@ -191,9 +174,7 @@ function handleFor<S>(
191
174
  return handle as unknown as RenderHandle<S>;
192
175
  }
193
176
 
194
- // ---------------------------------------------------------------------------
195
- // Segment / Body → ANSI string rendering. Lives here so it's the only place
196
- // that knows about theme colors + highlighting; renderers stay pure-data.
177
+ // Sole place that knows about theme colors + highlighting; renderers stay pure-data.
197
178
 
198
179
  import { highlight, supportsLanguage } from "cli-highlight";
199
180
 
@@ -271,9 +252,7 @@ function renderBody(body: Body, env: Env, diff: DiffSlot, exitCode?: number | nu
271
252
  }
272
253
  }
273
254
 
274
- // Lifted from ToolResultBody.repaint() in components.ts preview/summary/hidden
275
- // policy is host-wide display config, not per-tool, so it lives here once and
276
- // every schema renderer with a kind:"stream" body inherits it for free.
255
+ // Host-wide preview/summary/hidden policy inherited by every kind:"stream" body.
277
256
  function renderStream(buffer: string, env: Env, exitCode: number | null | undefined): string {
278
257
  const display = buffer.replace(/\n+$/, "");
279
258
  if (env.expanded) return theme.fg("toolOutput", display);
@@ -306,10 +285,7 @@ function lineCountHint(buffer: string, exitCode: number | null | undefined): str
306
285
  return `${arrow}${theme.fg("muted", label)}`;
307
286
  }
308
287
 
309
- // ---------------------------------------------------------------------------
310
- // Pi-tui Components produced by the adapter. Implement the existing
311
- // ToolCallView / ToolResultView contracts so the ashi resolver doesn't care
312
- // whether a renderer is legacy or schema-style.
288
+ // Components that satisfy the legacy ToolCallView / ToolResultView contracts.
313
289
 
314
290
  class SchemaCallComponent extends Container {
315
291
  private line: Text;
@@ -330,7 +306,16 @@ class SchemaCallComponent extends Container {
330
306
  const display = this.handle.model.view(this.handle.cell.state as ViewState<unknown>, this.handle.cell.env);
331
307
  const icon = iconString(display.titleIcon);
332
308
  const title = segmentsToString(display.title);
333
- this.line.setText(`${icon}${title}${statusSuffix(display.status)}`);
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
+ }
334
319
  }
335
320
  }
336
321
 
@@ -369,9 +354,8 @@ class SchemaResultComponent extends Container {
369
354
  const env = this.handle.cell.env;
370
355
  const display = this.handle.model.view(this.handle.cell.state as ViewState<unknown>, env);
371
356
  if (!display.body) { this.body.setText(""); return; }
372
- // kind:"stream" embeds preview/summary/hidden policy.
373
- // kind:"diff" shows in preview mode or when expanded.
374
- // Other kinds show iff expanded or the view requested defaultExpanded.
357
+ // stream embeds the preview/summary/hidden policy; diff shows in preview
358
+ // or when expanded; other kinds show only when expanded/defaultExpanded.
375
359
  if (display.body.kind === "diff" && !env.expanded && env.mode !== "preview") {
376
360
  this.body.setText("");
377
361
  return;
@@ -385,11 +369,6 @@ class SchemaResultComponent extends Container {
385
369
  }
386
370
  }
387
371
 
388
- // ---------------------------------------------------------------------------
389
- // Public mount functions used by hooks.ts when resolving a schema-style
390
- // renderer. Each returns a Component that satisfies the legacy view contract,
391
- // so the rest of ashi doesn't need to know schema renderers exist.
392
-
393
372
  export interface MountEnv {
394
373
  width: number;
395
374
  mode: Env["mode"];
@@ -44,7 +44,25 @@ export interface CompactionEntry {
44
44
  tokensBefore: number;
45
45
  }
46
46
 
47
- export type SessionEntry = SessionHeaderEntry | MessageEntry | CompactionEntry;
47
+ /** Omitted from buildMessages the agent already saw it via <shell_events>
48
+ * (or didn't, if private). The frontend replays it for scrollback fidelity. */
49
+ export interface ShellExchangeEntry {
50
+ type: "shell-exchange";
51
+ id: string;
52
+ parentId: string;
53
+ timestamp: number;
54
+ command: string;
55
+ output: string;
56
+ exitCode: number | null;
57
+ cwd?: string;
58
+ private?: boolean;
59
+ }
60
+
61
+ export type SessionEntry =
62
+ | SessionHeaderEntry
63
+ | MessageEntry
64
+ | CompactionEntry
65
+ | ShellExchangeEntry;
48
66
 
49
67
  export interface SessionMeta {
50
68
  name?: string;
@@ -96,13 +114,22 @@ export function summarizeMessage(m: AgentMessage): string {
96
114
  return `${role}: ${snippet(extractText(m.content), 500)}`;
97
115
  }
98
116
 
117
+ /** For displayed user text. Loops because both wrappers can stack at the head. */
118
+ export function stripContextWrappers(content: string): string {
119
+ let out = content;
120
+ for (;;) {
121
+ const next = out.replace(/^\s*<(query_context|dynamic_context)>[\s\S]*?<\/\1>\s*/, "");
122
+ if (next === out) return out;
123
+ out = next;
124
+ }
125
+ }
126
+
99
127
  export function renderEvictedSummary(evicted: AgentMessage[]): string {
100
128
  const lines = evicted.map((m) => `- ${summarizeMessage(m)}`);
101
129
  return `${lines.length} message(s) elided\n${lines.join("\n")}`;
102
130
  }
103
131
 
104
- /** One session = one JSONL file (entries) + sidecar files for leaf & meta.
105
- * Tree is implicit via parentId pointers; entries kept in memory after load. */
132
+ /** Tree is implicit via parentId pointers; entries are kept in memory after load. */
106
133
  export class SessionStore {
107
134
  private entriesPath: string;
108
135
  private leafPath: string;
@@ -170,9 +197,6 @@ export class SessionStore {
170
197
  this.persistMeta();
171
198
  }
172
199
 
173
- /** Append messages as a chain of MessageEntry, each parented at the
174
- * previously appended id (starting from current leaf). Returns the new
175
- * entry ids in order. */
176
200
  async appendMessages(messages: AgentMessage[]): Promise<string[]> {
177
201
  if (messages.length === 0) return [];
178
202
  this.flushHeader();
@@ -198,6 +222,32 @@ export class SessionStore {
198
222
  return newIds;
199
223
  }
200
224
 
225
+ async appendShellExchange(e: {
226
+ command: string;
227
+ output: string;
228
+ exitCode: number | null;
229
+ cwd?: string;
230
+ private?: boolean;
231
+ }): Promise<string> {
232
+ this.flushHeader();
233
+ const entry: ShellExchangeEntry = {
234
+ type: "shell-exchange",
235
+ id: newEntryId(),
236
+ parentId: this.activeLeaf,
237
+ timestamp: Date.now(),
238
+ command: e.command,
239
+ output: e.output,
240
+ exitCode: e.exitCode,
241
+ ...(e.cwd !== undefined ? { cwd: e.cwd } : {}),
242
+ ...(e.private ? { private: true } : {}),
243
+ };
244
+ this.entries.set(entry.id, entry);
245
+ this.activeLeaf = entry.id;
246
+ await fsp.appendFile(this.entriesPath, JSON.stringify(entry) + "\n");
247
+ this.persistLeaf();
248
+ return entry.id;
249
+ }
250
+
201
251
  async appendCompaction(firstKeptId: string, tokensBefore: number, summary?: string): Promise<string> {
202
252
  if (!this.entries.has(firstKeptId)) throw new Error(`firstKeptId unknown: ${firstKeptId}`);
203
253
  this.flushHeader();
@@ -217,7 +267,7 @@ export class SessionStore {
217
267
  return e.id;
218
268
  }
219
269
 
220
- /** Walk parent pointers from a leaf back to the root. Returns oldest-first. */
270
+ /** Oldest-first walk from leaf to root. */
221
271
  getBranch(leafId: string = this.activeLeaf): SessionEntry[] {
222
272
  const out: SessionEntry[] = [];
223
273
  const seen = new Set<string>();
@@ -232,9 +282,7 @@ export class SessionStore {
232
282
  return out.reverse();
233
283
  }
234
284
 
235
- /** Reconstruct the live message array for the active leaf, honoring the
236
- * latest compaction on the branch (summary + kept tail). Mirrors pi's
237
- * buildSessionContext. */
285
+ /** Honors the latest compaction on the branch (summary + kept tail). */
238
286
  buildMessages(leafId: string = this.activeLeaf): AgentMessage[] {
239
287
  const branch = this.getBranch(leafId);
240
288
  let compactionIdx = -1;
@@ -265,12 +313,11 @@ export class SessionStore {
265
313
  return out;
266
314
  }
267
315
 
268
- /** A short, human-friendly preview for picker rows. Uses the first user
269
- * message's text when available, else the session id. */
270
316
  getPreview(): string {
271
317
  for (const e of this.entries.values()) {
272
318
  if (e.type === "message" && e.message.role === "user") {
273
- const txt = typeof e.message.content === "string" ? e.message.content : "";
319
+ const raw = typeof e.message.content === "string" ? e.message.content : "";
320
+ const txt = stripContextWrappers(raw);
274
321
  if (txt) return txt.slice(0, 80);
275
322
  }
276
323
  }
@@ -0,0 +1,52 @@
1
+ export interface ChangeHandlerResult {
2
+ mode: boolean;
3
+ /** When set, caller must `editor.setText(replaceText)` to strip the `!`. */
4
+ replaceText?: string;
5
+ /** Whether the next submit (if in shell mode) is marked private. */
6
+ pendingPrivate: boolean;
7
+ }
8
+
9
+ /** Strips `!` (entry), `!!` (entry + private), in-mode `!` (upgrade to private).
10
+ * Shell mode is sticky — exit only via the Backspace-on-empty intercept;
11
+ * auto-exit on empty text would fire during pi-tui's pre-emptive onChange("")
12
+ * inside Editor.submitValue() and misroute the submit. pendingPrivate is
13
+ * sticky for the same reason. */
14
+ export function deriveChangeHandlerResult(
15
+ mode: boolean,
16
+ pendingPrivate: boolean,
17
+ text: string,
18
+ ): ChangeHandlerResult {
19
+ if (!mode && text.startsWith("!!")) {
20
+ return { mode: true, replaceText: text.slice(2), pendingPrivate: true };
21
+ }
22
+ if (!mode && text.startsWith("!")) {
23
+ return { mode: true, replaceText: text.slice(1), pendingPrivate: false };
24
+ }
25
+ if (mode && text.startsWith("!")) {
26
+ return { mode: true, replaceText: text.slice(1), pendingPrivate: true };
27
+ }
28
+ return { mode, pendingPrivate: pendingPrivate && mode };
29
+ }
30
+
31
+ export type SubmitAction =
32
+ | { kind: "noop" }
33
+ | { kind: "shell"; line: string; private: boolean }
34
+ | { kind: "command"; name: string; args: string }
35
+ | { kind: "agent"; query: string };
36
+
37
+ export function classifySubmit(
38
+ text: string,
39
+ shellMode: boolean,
40
+ pendingPrivate: boolean,
41
+ ): SubmitAction {
42
+ const query = text.trim();
43
+ if (!query) return { kind: "noop" };
44
+ if (shellMode) return { kind: "shell", line: query, private: pendingPrivate };
45
+ if (query.startsWith("/")) {
46
+ const sp = query.indexOf(" ");
47
+ const name = sp === -1 ? query : query.slice(0, sp);
48
+ const args = sp === -1 ? "" : query.slice(sp + 1).trim();
49
+ return { kind: "command", name, args };
50
+ }
51
+ return { kind: "agent", query };
52
+ }
@@ -12,6 +12,7 @@ interface StatusFields {
12
12
  tokens?: number;
13
13
  compactions?: number;
14
14
  thinking?: string;
15
+ shellMode?: "off" | "on" | "private";
15
16
  }
16
17
 
17
18
  export class StatusFooter extends Container {
@@ -40,12 +41,25 @@ export class StatusFooter extends Container {
40
41
 
41
42
  private repaint(width: number): void {
42
43
  const contentWidth = width > 0 ? Math.max(1, width - 2) : 0;
44
+ const right = this.buildRight();
45
+ const rightWidth = visibleWidth(right);
46
+ const join = (left: string): string => {
47
+ if (!right) return left;
48
+ const leftWidth = visibleWidth(left);
49
+ const gap = Math.max(1, contentWidth - leftWidth - rightWidth);
50
+ return `${left}${" ".repeat(gap)}${right}`;
51
+ };
43
52
  const full = this.buildLine("full");
44
- if (contentWidth === 0 || visibleWidth(full) <= contentWidth) {
45
- this.text.setText(full);
46
- return;
47
- }
48
- this.text.setText(this.buildLine("basename"));
53
+ const fullFits = contentWidth === 0
54
+ || visibleWidth(full) + (right ? rightWidth + 1 : 0) <= contentWidth;
55
+ this.text.setText(fullFits ? join(full) : join(this.buildLine("basename")));
56
+ }
57
+
58
+ private buildRight(): string {
59
+ const mode = this.fields.shellMode;
60
+ if (mode === "on") return theme.fg("bashMode", "▸ shell");
61
+ if (mode === "private") return theme.fg("bashModePrivate", "▸ shell · private");
62
+ return "";
49
63
  }
50
64
 
51
65
  private buildLine(cwdMode: "full" | "basename"): string {
@@ -53,7 +53,8 @@ const RAW = {
53
53
  toolDiffAdded: "green",
54
54
  toolDiffRemoved: "red",
55
55
  toolDiffContext: "gray",
56
- bashMode: "green",
56
+ bashMode: "yellow",
57
+ bashModePrivate: "green",
57
58
  } as const;
58
59
 
59
60
  export type ThemeColor = keyof typeof RAW;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.14.2",
3
+ "version": "0.14.4",
4
4
  "description": "A shell-first terminal where AI is one keystroke away",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -49,6 +49,10 @@
49
49
  "types": "./dist/shell/terminal.d.ts",
50
50
  "default": "./dist/shell/terminal.js"
51
51
  },
52
+ "./shell/context": {
53
+ "types": "./dist/shell/shell-context.d.ts",
54
+ "default": "./dist/shell/shell-context.js"
55
+ },
52
56
  "./utils/stream-transform": {
53
57
  "types": "./dist/utils/stream-transform.d.ts",
54
58
  "default": "./dist/utils/stream-transform.js"