agent-sh 0.10.1 → 0.10.2

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 CHANGED
@@ -4,6 +4,7 @@ An agent that lives in a shell — not a shell that lives in an agent.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/agent-sh.svg)](https://www.npmjs.com/package/agent-sh)
6
6
  [![license](https://img.shields.io/npm/l/agent-sh.svg)](https://github.com/guanyilun/agent-sh/blob/main/LICENSE)
7
+ [![website](https://img.shields.io/badge/website-agent--sh.dev-blue)](https://agent-sh.dev)
7
8
 
8
9
  ![demo](assets/demo.gif)
9
10
 
@@ -280,6 +280,9 @@ export class AgentLoop {
280
280
  if (beforeTokens > this.peakConversationTokens) {
281
281
  this.peakConversationTokens = beforeTokens;
282
282
  }
283
+ // The "File unchanged" stub assumes the prior read output is still
284
+ // in context; compaction can evict it. Clear so the next read re-emits.
285
+ this.fileReadCache.clear();
283
286
  });
284
287
  on("shell:cwd-change", ({ cwd }) => {
285
288
  const projectSkills = discoverProjectSkills(cwd);
@@ -1035,7 +1038,10 @@ export class AgentLoop {
1035
1038
  const contextWindow = this.currentMode.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
1036
1039
  const threshold = Math.floor((contextWindow - RESPONSE_RESERVE) * getSettings().autoCompactThreshold);
1037
1040
  if (totalEstimate > threshold) {
1038
- const result = this.compactWithHooks(threshold);
1041
+ // Compact deeply — shallow targets buy only 1–2 turns of runway on
1042
+ // tool-heavy workloads.
1043
+ const target = Math.floor(threshold * 0.25);
1044
+ const result = this.compactWithHooks(target, 6);
1039
1045
  if (!result) {
1040
1046
  // Auto-compact fired but nothing was evictable. This can happen
1041
1047
  // in short conversations with heavy tool output where the pin
@@ -43,6 +43,8 @@ export declare function createSessionMarker(iid: string, seq?: number): NuclearE
43
43
  export declare function isSessionMarker(entry: NuclearEntry): boolean;
44
44
  /** Read-only tools whose results are dropped at Tier 1→2 (agent can re-read). */
45
45
  export declare const READ_ONLY_TOOLS: Set<string>;
46
+ export declare function registerReadOnlyTool(name: string): void;
47
+ export declare function unregisterReadOnlyTool(name: string): void;
46
48
  /** State-changing tools whose summaries are kept in nuclear memory. */
47
49
  export declare const WRITE_TOOLS: Set<string>;
48
50
  /**
@@ -15,6 +15,14 @@ export function isSessionMarker(entry) {
15
15
  export const READ_ONLY_TOOLS = new Set([
16
16
  "read_file", "grep", "glob", "ls", "search",
17
17
  ]);
18
+ /** Extensions opt their tools in via ToolRegistry.register when readOnly is set. */
19
+ const extraReadOnlyTools = new Set();
20
+ export function registerReadOnlyTool(name) {
21
+ extraReadOnlyTools.add(name);
22
+ }
23
+ export function unregisterReadOnlyTool(name) {
24
+ extraReadOnlyTools.delete(name);
25
+ }
18
26
  /** State-changing tools whose summaries are kept in nuclear memory. */
19
27
  export const WRITE_TOOLS = new Set([
20
28
  "write_file", "edit_file", "write", "edit", "patch",
@@ -188,7 +196,9 @@ export function deserializeEntry(line) {
188
196
  // ── Classification helpers ────────────────────────────────────────
189
197
  /** Check if a nuclear entry represents a read-only action (should be dropped). */
190
198
  export function isReadOnly(entry) {
191
- return entry.kind === "tool" && entry.tool != null && READ_ONLY_TOOLS.has(entry.tool);
199
+ if (entry.kind !== "tool" || entry.tool == null)
200
+ return false;
201
+ return READ_ONLY_TOOLS.has(entry.tool) || extraReadOnlyTools.has(entry.tool);
192
202
  }
193
203
  // ── Internal helpers ──────────────────────────────────────────────
194
204
  function truncate(text, maxLen) {
@@ -89,7 +89,7 @@ function loadConventionFiles(dir) {
89
89
  * Static system prompt — identical across all queries, cacheable.
90
90
  * Contains only identity and behavioral instructions.
91
91
  */
92
- export const STATIC_SYSTEM_PROMPT = `You are an AI coding assistant running inside agent-sh, a terminal shell.
92
+ export const STATIC_SYSTEM_PROMPT = `You are ash, an AI coding assistant running inside agent-sh, a terminal shell.
93
93
  You have access to the user's shell environment and can read, write, and execute code.
94
94
  You share the user's working directory, environment variables, and shell history.
95
95
  agent-sh documentation is at ${path.join(CODE_DIR, "docs")} — start with README.md for an index. Read the docs when you need to understand how the runtime works.
@@ -1,3 +1,4 @@
1
+ import { registerReadOnlyTool, unregisterReadOnlyTool } from "./nuclear-form.js";
1
2
  /**
2
3
  * Registry for agent tools. Holds tool definitions and converts them
3
4
  * to OpenAI-compatible function schemas for API calls.
@@ -6,9 +7,14 @@ export class ToolRegistry {
6
7
  tools = new Map();
7
8
  register(tool) {
8
9
  this.tools.set(tool.name, tool);
10
+ if (tool.readOnly)
11
+ registerReadOnlyTool(tool.name);
12
+ else
13
+ unregisterReadOnlyTool(tool.name);
9
14
  }
10
15
  unregister(name) {
11
16
  this.tools.delete(name);
17
+ unregisterReadOnlyTool(name);
12
18
  }
13
19
  get(name) {
14
20
  return this.tools.get(name);
@@ -77,6 +77,9 @@ export interface ToolDefinition {
77
77
  showOutput?: boolean;
78
78
  /** Whether this tool may modify files — triggers file watcher (default: false). */
79
79
  modifiesFiles?: boolean;
80
+ /** Results are re-fetchable; nuclear compaction drops the tool_result
81
+ * body on eviction (like the builtin read_file/grep/ls). Default: false. */
82
+ readOnly?: boolean;
80
83
  /** Whether to gate execution via permission:request (default: false). */
81
84
  requiresPermission?: boolean;
82
85
  /** Derive display metadata (icon kind, file paths) for the TUI. */
@@ -48,7 +48,8 @@ function createRenderState() {
48
48
  spinnerOpts: {},
49
49
  spinnerInterval: null,
50
50
  spinnerStartTime: 0,
51
- toolLineOpen: false,
51
+ openTool: null,
52
+ pendingToolCompletes: new Map(),
52
53
  currentToolKind: undefined,
53
54
  toolStartTime: 0,
54
55
  toolExitCode: null,
@@ -58,6 +59,7 @@ function createRenderState() {
58
59
  commandOverflowLines: [],
59
60
  toolGroupKind: undefined,
60
61
  toolGroupCount: 0,
62
+ toolGroupCompletedCount: 0,
61
63
  toolGroupAllOk: true,
62
64
  toolGroupRendered: 0,
63
65
  toolGroupSummaries: [],
@@ -231,6 +233,9 @@ export default function activate(ctx) {
231
233
  return;
232
234
  s.isThinking = false;
233
235
  if (pendingUsage && s.renderer) {
236
+ // Flush any buffered partial line first — otherwise responses that
237
+ // don't end with a newline emit the usage line before their final text.
238
+ s.renderer.flush();
234
239
  const { prompt_tokens, completion_tokens } = pendingUsage;
235
240
  const maxTokens = backendInfo?.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
236
241
  s.renderer.writeLine("");
@@ -300,29 +305,26 @@ export default function activate(ctx) {
300
305
  group.headerShown = true;
301
306
  s.toolGroupKind = kind;
302
307
  s.toolGroupCount = 0;
308
+ s.toolGroupCompletedCount = 0;
303
309
  s.toolGroupRendered = 0;
304
310
  s.toolGroupAllOk = true;
305
311
  s.toolGroupSummaries = [];
306
312
  }
307
313
  s.toolGroupCount++;
308
314
  if (s.toolGroupRendered < GROUP_MAX_VISIBLE) {
309
- showToolCall(e.title, "", {
310
- ...e,
311
- batchIndex: e.batchIndex,
312
- batchTotal: e.batchTotal,
313
- groupContinuation: true,
314
- });
315
+ showToolCall(e.title, "", { ...e, groupContinuation: true });
315
316
  s.toolGroupRendered++;
316
317
  }
318
+ // Record identity so late completes (after a premature finalize
319
+ // from a cross-kind standalone start) can render as labeled ⎿ lines.
320
+ if (e.toolCallId) {
321
+ s.pendingToolCompletes.set(e.toolCallId, { title: e.title });
322
+ }
317
323
  }
318
324
  else {
319
325
  // Standalone tool — single in its batch kind, or not groupable
320
326
  finalizeToolGroup();
321
- showToolCall(e.title, "", {
322
- ...e,
323
- batchIndex: e.batchIndex,
324
- batchTotal: e.batchTotal,
325
- });
327
+ showToolCall(e.title, "", { ...e });
326
328
  }
327
329
  });
328
330
  bus.on("agent:tool-completed", (e) => {
@@ -336,10 +338,17 @@ export default function activate(ctx) {
336
338
  // Don't restart spinner between grouped tools — it's already running from group start.
337
339
  if (e.resultDisplay?.summary)
338
340
  s.toolGroupSummaries.push(e.resultDisplay.summary);
341
+ if (e.toolCallId)
342
+ s.pendingToolCompletes.delete(e.toolCallId);
343
+ s.toolGroupCompletedCount++;
339
344
  s.currentToolKind = undefined;
340
345
  }
341
346
  else {
342
- showToolComplete(e.exitCode, e.resultDisplay);
347
+ // Route by callId — tools that lost the inline slot get a labeled ⎿ line.
348
+ const pending = e.toolCallId ? s.pendingToolCompletes.get(e.toolCallId) : undefined;
349
+ if (pending)
350
+ s.pendingToolCompletes.delete(e.toolCallId);
351
+ showToolComplete(e.exitCode, e.resultDisplay, pending?.title);
343
352
  s.currentToolKind = undefined;
344
353
  s.spinnerStartTime = 0;
345
354
  startThinkingSpinner();
@@ -734,37 +743,37 @@ export default function activate(ctx) {
734
743
  // Grouped tools: close the line immediately — checkmarks go on the ⎿ summary
735
744
  s.renderer.writeLine(` ${batchPrefix}${lines[lines.length - 1]}`);
736
745
  drain();
737
- s.toolLineOpen = false;
738
746
  }
739
747
  else {
740
748
  out().write(` ${batchPrefix}${lines[lines.length - 1]}`);
741
- s.toolLineOpen = true;
749
+ if (extra?.toolCallId)
750
+ s.openTool = { callId: extra.toolCallId, title };
742
751
  }
743
752
  }
744
753
  s.hadToolCalls = true;
745
754
  s.commandOutputLineCount = 0;
746
755
  s.commandOutputOverflow = 0;
747
756
  }
748
- function showToolComplete(exitCode, resultDisplay) {
757
+ function showToolComplete(exitCode, resultDisplay, labelTitle) {
749
758
  if (!s.renderer)
750
759
  return;
751
760
  stopCurrentSpinner();
752
761
  const elapsed = s.toolStartTime ? formatElapsed(Date.now() - s.toolStartTime) : "";
753
762
  const mark = ctx.call("tui:render-tool-complete", exitCode, elapsed, resultDisplay?.summary);
754
- if (s.toolLineOpen && s.commandOutputLineCount === 0) {
763
+ if (!labelTitle && s.openTool && s.commandOutputLineCount === 0) {
755
764
  out().write(` ${mark}\n`);
756
- s.toolLineOpen = false;
765
+ s.openTool = null;
757
766
  }
758
767
  else {
759
768
  closeToolLine();
760
769
  flushCommandOutput();
761
- s.renderer.writeLine(` ${mark}`);
770
+ s.renderer.writeLine(labelTitle
771
+ ? ` ${p.muted}⎿${p.reset} ${p.dim}${labelTitle}${p.reset} ${mark}`
772
+ : ` ${mark}`);
762
773
  drain();
763
774
  }
764
- // Render structured body if present
765
- if (resultDisplay?.body) {
775
+ if (resultDisplay?.body)
766
776
  renderResultBody(resultDisplay.body);
767
- }
768
777
  }
769
778
  function renderResultBody(body) {
770
779
  if (!s.renderer)
@@ -813,18 +822,23 @@ export default function activate(ctx) {
813
822
  }
814
823
  }
815
824
  function closeToolLine() {
816
- if (s.toolLineOpen) {
825
+ if (s.openTool) {
817
826
  out().write("\n");
818
- s.toolLineOpen = false;
827
+ // Stash identity so the completion renders as ⎿ labeled, not orphan ✓.
828
+ s.pendingToolCompletes.set(s.openTool.callId, { title: s.openTool.title });
829
+ s.openTool = null;
819
830
  }
820
831
  }
821
- /** Finalize a group of collapsed tool calls, rendering the summary. */
832
+ /** Render the group aggregate line, or skip if no members have
833
+ * completed yet (late completes will render individually as ⎿ labeled). */
822
834
  function finalizeToolGroup() {
823
- if (s.toolGroupCount <= 1) {
824
- // 0–1 tools: standalone, nothing to finalize
835
+ const skipAggregate = s.toolGroupCount > 1 && s.toolGroupCompletedCount === 0;
836
+ if (s.toolGroupCount <= 1 || skipAggregate) {
825
837
  s.toolGroupKind = undefined;
826
838
  s.toolGroupCount = 0;
839
+ s.toolGroupCompletedCount = 0;
827
840
  s.toolGroupRendered = 0;
841
+ s.toolGroupAllOk = true;
828
842
  s.toolGroupSummaries = [];
829
843
  return;
830
844
  }
@@ -836,6 +850,7 @@ export default function activate(ctx) {
836
850
  drain();
837
851
  s.toolGroupKind = undefined;
838
852
  s.toolGroupCount = 0;
853
+ s.toolGroupCompletedCount = 0;
839
854
  s.toolGroupAllOk = true;
840
855
  s.toolGroupRendered = 0;
841
856
  s.toolGroupSummaries = [];
@@ -218,6 +218,12 @@ export class InputHandler {
218
218
  this.lineBuffer = "";
219
219
  this.ctx.writeToPty(ch);
220
220
  }
221
+ else if (ch === "\x0b" || ch === "\x15") {
222
+ // Ctrl-K / Ctrl-U kill the line in the shell; mirror that so the
223
+ // mode-trigger check sees an empty buffer. Not cursor-accurate.
224
+ this.lineBuffer = "";
225
+ this.ctx.writeToPty(ch);
226
+ }
221
227
  else if (ch === "\x1b") {
222
228
  // Escape sequence — forward the entire sequence to the PTY but
223
229
  // don't let it corrupt lineBuffer. Skip CSI (ESC [ ... final)
@@ -23,6 +23,9 @@ export declare function visibleLen(str: string): number;
23
23
  * Accounts for CJK double-width characters. Appends `…` if truncated.
24
24
  */
25
25
  export declare function truncateToWidth(str: string, maxWidth: number): string;
26
+ /** Truncate to visible width while preserving SGR sequences — use when
27
+ * input carries color/bold codes. `truncateToWidth` strips them. */
28
+ export declare function truncateAnsiToWidth(str: string, maxWidth: number): string;
26
29
  /**
27
30
  * Pad a string with spaces to fill `targetWidth` visible columns.
28
31
  * Accounts for CJK double-width characters.
@@ -52,9 +52,22 @@ export function charWidth(codePoint) {
52
52
  if (codePoint >= 0x1fa00 && codePoint <= 0x1faff)
53
53
  return 2; // Chess Symbols, Symbols and Pictographs Extended-A
54
54
  // NOTE: 0x2300-0x23ff (Misc Technical), 0x2600-0x26ff (Misc Symbols),
55
- // and 0x2700-0x27bf (Dingbats) are intentionally NOT width 2 these ranges
56
- // contain mostly "Ambiguous" width characters that render as 1 column in
57
- // non-CJK terminal locales (e.g. ❯, ⌘, ★, ♦).
55
+ // and 0x2700-0x27bf (Dingbats) are mostly "Ambiguous" width — render as
56
+ // 1 column in non-CJK terminal locales (e.g. ❯, ⌘, ★, ♦). But a handful
57
+ // of dingbats have Emoji_Presentation=Yes and render as 2 cols everywhere.
58
+ if (codePoint === 0x2705 || // ✅ white heavy check mark
59
+ codePoint === 0x270a || // ✊ raised fist
60
+ codePoint === 0x270b || // ✋ raised hand
61
+ codePoint === 0x2728 || // ✨ sparkles
62
+ codePoint === 0x274c || // ❌ cross mark
63
+ codePoint === 0x274e || // ❎ negative squared cross mark
64
+ (codePoint >= 0x2753 && codePoint <= 0x2755) || // ❓❔❕
65
+ codePoint === 0x2757 || // ❗ heavy exclamation mark
66
+ (codePoint >= 0x2795 && codePoint <= 0x2797) || // ➕➖➗
67
+ codePoint === 0x27b0 || // ➰ curly loop
68
+ codePoint === 0x27bf // ➿ double curly loop
69
+ )
70
+ return 2;
58
71
  // Regional indicator symbols (flag emoji components)
59
72
  if (codePoint >= 0x1f1e6 && codePoint <= 0x1f1ff)
60
73
  return 2;
@@ -150,6 +163,39 @@ export function truncateToWidth(str, maxWidth) {
150
163
  return "…";
151
164
  return clean.slice(0, i) + "…";
152
165
  }
166
+ /** Truncate to visible width while preserving SGR sequences — use when
167
+ * input carries color/bold codes. `truncateToWidth` strips them. */
168
+ export function truncateAnsiToWidth(str, maxWidth) {
169
+ if (maxWidth <= 0)
170
+ return "";
171
+ if (visibleLen(str) <= maxWidth)
172
+ return str;
173
+ if (maxWidth === 1)
174
+ return "…";
175
+ const target = maxWidth - 1;
176
+ let width = 0;
177
+ let out = "";
178
+ let i = 0;
179
+ while (i < str.length) {
180
+ if (str[i] === "\x1b" && str[i + 1] === "[") {
181
+ const end = str.indexOf("m", i);
182
+ if (end !== -1) {
183
+ out += str.slice(i, end + 1);
184
+ i = end + 1;
185
+ continue;
186
+ }
187
+ }
188
+ const cp = str.codePointAt(i) ?? 0;
189
+ const cw = charWidth(cp);
190
+ if (width + cw > target)
191
+ break;
192
+ const chLen = cp > 0xffff ? 2 : 1;
193
+ out += str.slice(i, i + chLen);
194
+ width += cw;
195
+ i += chLen;
196
+ }
197
+ return out + "\x1b[0m…";
198
+ }
153
199
  /**
154
200
  * Pad a string with spaces to fill `targetWidth` visible columns.
155
201
  * Accounts for CJK double-width characters.
@@ -12,6 +12,10 @@ export interface LlmClientConfig {
12
12
  apiKey: string;
13
13
  baseURL?: string;
14
14
  model: string;
15
+ /** Sent as OpenRouter X-Title; ignored by other providers. */
16
+ appName?: string;
17
+ /** Sent as OpenRouter HTTP-Referer; ignored by other providers. */
18
+ appUrl?: string;
15
19
  }
16
20
  export declare class LlmClient {
17
21
  private config;
@@ -6,6 +6,12 @@
6
6
  * (command suggestions, completions).
7
7
  */
8
8
  import OpenAI from "openai";
9
+ function attributionHeaders(config) {
10
+ return {
11
+ "HTTP-Referer": config.appUrl ?? "https://agent-sh.dev",
12
+ "X-Title": config.appName ?? "agent-sh",
13
+ };
14
+ }
9
15
  export class LlmClient {
10
16
  config;
11
17
  client;
@@ -15,6 +21,7 @@ export class LlmClient {
15
21
  this.client = new OpenAI({
16
22
  apiKey: config.apiKey,
17
23
  baseURL: config.baseURL,
24
+ defaultHeaders: attributionHeaders(config),
18
25
  });
19
26
  this.model = config.model;
20
27
  }
@@ -24,6 +31,7 @@ export class LlmClient {
24
31
  this.client = new OpenAI({
25
32
  apiKey: newConfig.apiKey,
26
33
  baseURL: newConfig.baseURL,
34
+ defaultHeaders: attributionHeaders(newConfig),
27
35
  });
28
36
  this.model = newConfig.model;
29
37
  }
@@ -2,6 +2,10 @@ export declare const MAX_CONTENT_WIDTH = 90;
2
2
  /**
3
3
  * Word-wrap a string (which may contain ANSI codes) to a maximum visible width.
4
4
  * Returns an array of lines, each fitting within `maxWidth` visible characters.
5
+ *
6
+ * Handles CJK text by breaking between wide characters and applying basic
7
+ * CJK rules (closing punctuation sticks to the previous line; opening
8
+ * punctuation sticks to the next).
5
9
  */
6
10
  export declare function wrapLine(text: string, maxWidth: number): string[];
7
11
  /**
@@ -1,9 +1,65 @@
1
- import { visibleLen, truncateToWidth, padEndToWidth, charWidth } from "./ansi.js";
1
+ import { visibleLen, truncateAnsiToWidth, padEndToWidth, charWidth } from "./ansi.js";
2
2
  import { palette as p } from "./palette.js";
3
3
  export const MAX_CONTENT_WIDTH = 90;
4
+ // CJK line-breaking rules: closing punctuation must not start a line,
5
+ // opening punctuation must not end a line. Both CJK fullwidth and ASCII
6
+ // equivalents are included so mixed text wraps correctly.
7
+ const CJK_NO_LINE_START = new Set([
8
+ "。", ",", "、", ".", ";", ":", "!", "?",
9
+ ")", "」", "』", "】", "》", "〉", "〕", "]", "}",
10
+ "・", "々", "〜", "~", "ー",
11
+ ".", ",", ";", ":", "!", "?", ")", "]", "}",
12
+ ]);
13
+ const CJK_NO_LINE_END = new Set([
14
+ "(", "「", "『", "【", "《", "〈", "〔", "[", "{",
15
+ "(", "[", "{",
16
+ ]);
17
+ /**
18
+ * Tokenize a visible-text run into units suitable for wrapping.
19
+ * Each width-2 character (CJK, fullwidth, emoji) becomes its own token so the
20
+ * wrapper can break between them; ASCII runs stay together as word tokens.
21
+ */
22
+ function tokenizeVisible(text) {
23
+ const tokens = [];
24
+ let ascii = "";
25
+ const flush = () => { if (ascii) {
26
+ tokens.push(ascii);
27
+ ascii = "";
28
+ } };
29
+ let i = 0;
30
+ while (i < text.length) {
31
+ const cp = text.codePointAt(i) ?? 0;
32
+ const chLen = cp > 0xffff ? 2 : 1;
33
+ const ch = text.slice(i, i + chLen);
34
+ if (ch === " ") {
35
+ flush();
36
+ let spaces = "";
37
+ while (i < text.length && text[i] === " ") {
38
+ spaces += " ";
39
+ i += 1;
40
+ }
41
+ tokens.push(spaces);
42
+ continue;
43
+ }
44
+ if (charWidth(cp) === 2) {
45
+ flush();
46
+ tokens.push(ch);
47
+ i += chLen;
48
+ continue;
49
+ }
50
+ ascii += ch;
51
+ i += chLen;
52
+ }
53
+ flush();
54
+ return tokens;
55
+ }
4
56
  /**
5
57
  * Word-wrap a string (which may contain ANSI codes) to a maximum visible width.
6
58
  * Returns an array of lines, each fitting within `maxWidth` visible characters.
59
+ *
60
+ * Handles CJK text by breaking between wide characters and applying basic
61
+ * CJK rules (closing punctuation sticks to the previous line; opening
62
+ * punctuation sticks to the next).
7
63
  */
8
64
  export function wrapLine(text, maxWidth) {
9
65
  if (!(maxWidth > 0))
@@ -11,40 +67,44 @@ export function wrapLine(text, maxWidth) {
11
67
  if (visibleLen(text) <= maxWidth)
12
68
  return [text];
13
69
  const result = [];
14
- // Split into segments: ANSI codes and visible text
15
70
  const segments = text.match(/(\x1b\[[^m]*m|[^\x1b]+)/g) || [text];
16
- let currentLine = "";
17
- let currentWidth = 0;
18
- let activeStyles = ""; // track ANSI styles to reapply after wraps
71
+ let lineTokens = [];
72
+ let lineWidth = 0;
73
+ let activeStyles = "";
74
+ let lastVisibleIdx = -1;
75
+ const commit = () => {
76
+ result.push(lineTokens.join("") + p.reset);
77
+ lineTokens = activeStyles ? [activeStyles] : [];
78
+ lineWidth = 0;
79
+ lastVisibleIdx = -1;
80
+ };
19
81
  for (const seg of segments) {
20
82
  if (seg.startsWith("\x1b[")) {
21
- // ANSI code — track it, add to current line
22
- currentLine += seg;
23
- if (seg === p.reset) {
83
+ lineTokens.push(seg);
84
+ if (seg === p.reset)
24
85
  activeStyles = "";
25
- }
26
- else {
86
+ else
27
87
  activeStyles += seg;
28
- }
29
88
  continue;
30
89
  }
31
- // Visible text split into words
32
- const words = seg.split(/( +)/);
33
- for (const word of words) {
34
- if (word.length === 0)
90
+ for (const token of tokenizeVisible(seg)) {
91
+ const tokenWidth = visibleLen(token);
92
+ const isSpace = token[0] === " ";
93
+ if (lineWidth + tokenWidth <= maxWidth) {
94
+ lineTokens.push(token);
95
+ lineWidth += tokenWidth;
96
+ if (!isSpace)
97
+ lastVisibleIdx = lineTokens.length - 1;
35
98
  continue;
36
- const wordWidth = visibleLen(word);
37
- if (currentWidth + wordWidth <= maxWidth) {
38
- currentLine += word;
39
- currentWidth += wordWidth;
40
99
  }
41
- else if (currentWidth === 0) {
42
- // Single word longer than maxWidth — hard break by visible width
43
- let remaining = word;
100
+ // Token doesn't fit on the current line.
101
+ if (isSpace)
102
+ continue; // spaces at wrap points are dropped
103
+ if (lineWidth === 0) {
104
+ // Token longer than the entire line — hard-break by char width.
105
+ let remaining = token;
44
106
  while (remaining.length > 0) {
45
- // Find the largest prefix that fits
46
- let fitLen = 0;
47
- let fitWidth = 0;
107
+ let fitLen = 0, fitWidth = 0;
48
108
  for (const ch of remaining) {
49
109
  const cw = charWidth(ch.codePointAt(0) ?? 0);
50
110
  if (fitWidth + cw > maxWidth)
@@ -52,37 +112,47 @@ export function wrapLine(text, maxWidth) {
52
112
  fitWidth += cw;
53
113
  fitLen += ch.length;
54
114
  }
55
- if (fitLen === 0) {
56
- // Even one char doesn't fit — force take one char to avoid infinite loop
115
+ if (fitLen === 0)
57
116
  fitLen = remaining[0]?.length ?? 1;
58
- }
59
117
  const chunk = remaining.slice(0, fitLen);
60
118
  remaining = remaining.slice(fitLen);
61
- currentLine += chunk;
62
- if (remaining.length > 0) {
63
- result.push(currentLine + p.reset);
64
- currentLine = activeStyles;
65
- currentWidth = 0;
66
- }
67
- else {
68
- currentWidth += fitWidth;
69
- }
119
+ lineTokens.push(chunk);
120
+ lineWidth += visibleLen(chunk);
121
+ lastVisibleIdx = lineTokens.length - 1;
122
+ if (remaining.length > 0)
123
+ commit();
70
124
  }
125
+ continue;
71
126
  }
72
- else {
73
- // Wrap to next line
74
- result.push(currentLine + p.reset);
75
- currentLine = activeStyles;
76
- currentWidth = 0;
77
- // Skip leading spaces on new line
78
- const trimmed = word.replace(/^ +/, "");
79
- currentLine += trimmed;
80
- currentWidth = visibleLen(trimmed);
127
+ // Rule (a): closing punctuation must not start a line. Allow up to 2
128
+ // columns of overflow so the punctuation stays with its phrase.
129
+ if (CJK_NO_LINE_START.has(token)) {
130
+ lineTokens.push(token);
131
+ lineWidth += tokenWidth;
132
+ commit();
133
+ continue;
134
+ }
135
+ // Rule (b): opening punctuation must not end a line. Pull the trailing
136
+ // opener down to the next line with us.
137
+ let carried = [];
138
+ if (lastVisibleIdx >= 0 && CJK_NO_LINE_END.has(lineTokens[lastVisibleIdx])) {
139
+ carried = lineTokens.splice(lastVisibleIdx);
140
+ while (lineTokens.length > 0 && /^ +$/.test(lineTokens[lineTokens.length - 1])) {
141
+ lineTokens.pop();
142
+ }
143
+ }
144
+ commit();
145
+ for (const t of carried) {
146
+ lineTokens.push(t);
147
+ lineWidth += visibleLen(t);
81
148
  }
149
+ lineTokens.push(token);
150
+ lineWidth += tokenWidth;
151
+ lastVisibleIdx = lineTokens.length - 1;
82
152
  }
83
153
  }
84
- if (currentLine.length > 0) {
85
- result.push(currentLine);
154
+ if (lineWidth > 0) {
155
+ result.push(lineTokens.join(""));
86
156
  }
87
157
  return result;
88
158
  }
@@ -188,17 +258,17 @@ export class MarkdownRenderer {
188
258
  while (row.length < numCols)
189
259
  row.push("");
190
260
  }
191
- // Calculate column widths from content
261
+ // Width from rendered cell — raw `**bold**` over-counts by 4 per pair.
192
262
  const colWidths = new Array(numCols).fill(0);
193
263
  for (const row of dataRows) {
194
264
  for (let c = 0; c < numCols; c++) {
195
- colWidths[c] = Math.max(colWidths[c], visibleLen(row[c]));
265
+ colWidths[c] = Math.max(colWidths[c], visibleLen(this.renderInline(row[c])));
196
266
  }
197
267
  }
198
- // Shrink columns proportionally if total exceeds content width
199
- // Account for separators: " │ " between cols (3 chars each) + 2 outer padding
268
+ // Tables bypass the prose width cap borders guide the eye, so wider is fine.
200
269
  const separatorWidth = (numCols - 1) * 3;
201
- const availableWidth = this.contentWidth - separatorWidth;
270
+ const tableWidth = Math.max(10, this.width - 2);
271
+ const availableWidth = tableWidth - separatorWidth;
202
272
  const totalWidth = colWidths.reduce((a, b) => a + b, 0);
203
273
  if (totalWidth > availableWidth && availableWidth > numCols) {
204
274
  const scale = availableWidth / totalWidth;
@@ -216,7 +286,10 @@ export class MarkdownRenderer {
216
286
  const isHeader = hasHeader && i === 0;
217
287
  const cells = row.map((cell, c) => {
218
288
  const w = colWidths[c];
219
- const text = visibleLen(cell) > w ? truncateToWidth(cell, w) : padEndToWidth(cell, w);
289
+ const rendered = this.renderInline(cell);
290
+ const text = visibleLen(rendered) > w
291
+ ? truncateAnsiToWidth(rendered, w)
292
+ : padEndToWidth(rendered, w);
220
293
  return isHeader ? `${p.bold}${text}${p.reset}` : text;
221
294
  });
222
295
  this.writeLine(`${p.dim}│${p.reset} ${cells.join(` ${p.dim}│${p.reset} `)} ${p.dim}│${p.reset}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "description": "A shell-first terminal where AI is one keystroke away",
5
5
  "type": "module",
6
6
  "main": "dist/core.js",
@@ -70,6 +70,14 @@
70
70
  "types": "./dist/agent/token-budget.d.ts",
71
71
  "default": "./dist/agent/token-budget.js"
72
72
  },
73
+ "./agent/history-file": {
74
+ "types": "./dist/agent/history-file.d.ts",
75
+ "default": "./dist/agent/history-file.js"
76
+ },
77
+ "./agent/nuclear-form": {
78
+ "types": "./dist/agent/nuclear-form.d.ts",
79
+ "default": "./dist/agent/nuclear-form.js"
80
+ },
73
81
  "./executor": {
74
82
  "types": "./dist/executor.d.ts",
75
83
  "default": "./dist/executor.js"