agent-sh 0.15.0 → 0.15.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.
Files changed (124) hide show
  1. package/dist/agent/agent-loop.js +11 -8
  2. package/dist/agent/events.d.ts +4 -0
  3. package/docs/README.md +14 -0
  4. package/docs/agent.md +398 -0
  5. package/docs/architecture.md +196 -0
  6. package/docs/context-management.md +200 -0
  7. package/docs/extensions.md +951 -0
  8. package/docs/library.md +84 -0
  9. package/docs/troubleshooting.md +65 -0
  10. package/docs/tui-composition.md +294 -0
  11. package/docs/usage.md +306 -0
  12. package/examples/extensions/ash-scheme/package.json +1 -1
  13. package/examples/extensions/ashi/EXTENDING.md +2 -2
  14. package/examples/extensions/ashi/README.md +2 -2
  15. package/examples/extensions/ashi/docs/ui-surface-protocol.md +1 -1
  16. package/examples/extensions/ashi/package.json +5 -3
  17. package/examples/extensions/ashi/src/chat/tool-group.ts +3 -2
  18. package/examples/extensions/ashi/src/cli.ts +9 -8
  19. package/examples/extensions/ashi/src/dialogs.ts +16 -1
  20. package/examples/extensions/ashi/src/events.ts +1 -0
  21. package/examples/extensions/ashi/src/frontend.ts +26 -6
  22. package/examples/extensions/ashi/src/renderer.ts +24 -4
  23. package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +4 -3
  24. package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +5 -8
  25. package/examples/extensions/ashi/src/ui.ts +11 -0
  26. package/examples/extensions/ashi-ink/package.json +2 -2
  27. package/examples/extensions/claude-code-bridge/package.json +1 -1
  28. package/examples/extensions/opencode-bridge/package.json +1 -1
  29. package/package.json +3 -1
  30. package/src/agent/agent-loop.ts +1566 -0
  31. package/src/agent/entry-format.ts +19 -0
  32. package/src/agent/events.ts +153 -0
  33. package/src/agent/extensions/rolling-history/constants.ts +1 -0
  34. package/src/agent/extensions/rolling-history/index.ts +202 -0
  35. package/src/agent/extensions/rolling-history/recall.ts +131 -0
  36. package/src/agent/extensions/rolling-history/strategy.ts +404 -0
  37. package/src/agent/host-types.ts +192 -0
  38. package/src/agent/index.ts +591 -0
  39. package/src/agent/live-view.ts +279 -0
  40. package/src/agent/llm-client.ts +111 -0
  41. package/src/agent/llm-facade.ts +43 -0
  42. package/src/agent/normalize-args.ts +61 -0
  43. package/src/agent/nuclear-form.ts +382 -0
  44. package/src/agent/providers/deepseek.ts +39 -0
  45. package/src/agent/providers/ollama.ts +92 -0
  46. package/src/agent/providers/openai-compatible.ts +36 -0
  47. package/src/agent/providers/openai.ts +52 -0
  48. package/src/agent/providers/opencode.ts +142 -0
  49. package/src/agent/providers/openrouter.ts +105 -0
  50. package/src/agent/providers/zai-coding-plan.ts +33 -0
  51. package/src/agent/session-store.ts +336 -0
  52. package/src/agent/skills.ts +228 -0
  53. package/src/agent/store.ts +310 -0
  54. package/src/agent/subagent.ts +305 -0
  55. package/src/agent/system-prompt.ts +151 -0
  56. package/src/agent/token-budget.ts +12 -0
  57. package/src/agent/tool-protocol.ts +722 -0
  58. package/src/agent/tool-registry.ts +66 -0
  59. package/src/agent/tools/bash.ts +95 -0
  60. package/src/agent/tools/edit-file.ts +154 -0
  61. package/src/agent/tools/expand-home.ts +7 -0
  62. package/src/agent/tools/glob.ts +108 -0
  63. package/src/agent/tools/grep.ts +228 -0
  64. package/src/agent/tools/list-skills.ts +37 -0
  65. package/src/agent/tools/ls.ts +81 -0
  66. package/src/agent/tools/pwsh.ts +140 -0
  67. package/src/agent/tools/read-file.ts +164 -0
  68. package/src/agent/tools/write-file.ts +72 -0
  69. package/src/agent/types.ts +149 -0
  70. package/src/cli/args.ts +91 -0
  71. package/src/cli/auth/cli.ts +244 -0
  72. package/src/cli/auth/discover.ts +52 -0
  73. package/src/cli/auth/keys.ts +143 -0
  74. package/src/cli/index.ts +295 -0
  75. package/src/cli/init.ts +74 -0
  76. package/src/cli/install.ts +439 -0
  77. package/src/cli/shell-env.ts +68 -0
  78. package/src/cli/subcommands.ts +24 -0
  79. package/src/core/event-bus.ts +252 -0
  80. package/src/core/extension-loader.ts +347 -0
  81. package/src/core/index.ts +152 -0
  82. package/src/core/settings.ts +398 -0
  83. package/src/core/types.ts +61 -0
  84. package/src/extensions/file-autocomplete.ts +71 -0
  85. package/src/extensions/index.ts +38 -0
  86. package/src/extensions/slash-commands/events.ts +14 -0
  87. package/src/extensions/slash-commands/index.ts +269 -0
  88. package/src/shell/events.ts +73 -0
  89. package/src/shell/host-types.ts +150 -0
  90. package/src/shell/index.ts +159 -0
  91. package/src/shell/input-handler.ts +505 -0
  92. package/src/shell/output-parser.ts +156 -0
  93. package/src/shell/shell-context.ts +193 -0
  94. package/src/shell/shell.ts +414 -0
  95. package/src/shell/strategies/bash.ts +83 -0
  96. package/src/shell/strategies/fish.ts +77 -0
  97. package/src/shell/strategies/index.ts +24 -0
  98. package/src/shell/strategies/types.ts +64 -0
  99. package/src/shell/strategies/zsh.ts +92 -0
  100. package/src/shell/terminal.ts +124 -0
  101. package/src/shell/tui-input-view.ts +222 -0
  102. package/src/shell/tui-renderer.ts +1126 -0
  103. package/src/utils/ansi.ts +140 -0
  104. package/src/utils/box-frame.ts +138 -0
  105. package/src/utils/compositor.ts +157 -0
  106. package/src/utils/diff-renderer.ts +829 -0
  107. package/src/utils/diff.ts +244 -0
  108. package/src/utils/executor.ts +305 -0
  109. package/src/utils/file-watcher.ts +110 -0
  110. package/src/utils/floating-panel.ts +1160 -0
  111. package/src/utils/handler-registry.ts +110 -0
  112. package/src/utils/line-editor.ts +636 -0
  113. package/src/utils/markdown.ts +437 -0
  114. package/src/utils/message-utils.ts +113 -0
  115. package/src/utils/package-version.ts +12 -0
  116. package/src/utils/palette.ts +64 -0
  117. package/src/utils/ref-counter.ts +9 -0
  118. package/src/utils/ripgrep-path.ts +17 -0
  119. package/src/utils/shell-output-spill.ts +76 -0
  120. package/src/utils/stream-transform.ts +292 -0
  121. package/src/utils/terminal-buffer.ts +213 -0
  122. package/src/utils/tool-display.ts +315 -0
  123. package/src/utils/tool-interactive.ts +71 -0
  124. package/src/utils/tty.ts +14 -0
@@ -0,0 +1,92 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import type { ShellStrategy, PrepareSpawnOpts, ShellSpawnConfig } from "./types.js";
4
+
5
+ const OSC7_CMD = 'printf "\\e]7;file://%s%s\\a" "$(hostname)" "$PWD"';
6
+ const TITLE_CMD = 'printf "\\e]0;⚡ agent-sh: %s\\a" "${PWD/#$HOME/~}"';
7
+
8
+ export const zshStrategy: ShellStrategy = {
9
+ name: "zsh",
10
+
11
+ matches(shellPath: string): boolean {
12
+ return path.basename(shellPath).includes("zsh");
13
+ },
14
+
15
+ prepareSpawn(opts: PrepareSpawnOpts): ShellSpawnConfig {
16
+ const { tmpDirRoot, instanceTag, showIndicator, env, userHome } = opts;
17
+
18
+ // Use ZDOTDIR to source user's real config, then append our hooks via
19
+ // precmd_functions (additive — doesn't clobber p10k/omz).
20
+ const tmpDir = fs.mkdtempSync(path.join(tmpDirRoot, "agent-sh-"));
21
+ const userZdotdir = env.ZDOTDIR || env.HOME || userHome;
22
+ const promptMarker = `printf "\\e]9999;${instanceTag};PROMPT\\a"`;
23
+
24
+ const lines = [
25
+ `ZDOTDIR="${userZdotdir}"`,
26
+ `[ -f "${userZdotdir}/.zshrc" ] && source "${userZdotdir}/.zshrc"`,
27
+ "",
28
+ "# agent-sh hooks (invisible OSC sequences for cwd + prompt detection)",
29
+ "__agent_sh_precmd() {",
30
+ ` ${OSC7_CMD}`,
31
+ ` ${promptMarker}`,
32
+ ...(showIndicator ? [` ${TITLE_CMD}`] : []),
33
+ "}",
34
+ "precmd_functions+=(__agent_sh_precmd)",
35
+ "",
36
+ "# Preexec hook: emit actual command text so agent-sh can track",
37
+ "# history-recalled and tab-completed commands accurately",
38
+ "__agent_sh_preexec() {",
39
+ ` printf "\\e]9997;${instanceTag};%s\\a" "$1"`,
40
+ "}",
41
+ "preexec_functions+=(__agent_sh_preexec)",
42
+ "",
43
+ "# End-of-prompt marker via zle-line-init (fires after prompt is rendered)",
44
+ "# Chain onto existing widget (p10k uses zle-line-init) rather than clobbering",
45
+ 'if (( ${+widgets[zle-line-init]} )); then',
46
+ " zle -A zle-line-init __agent_sh_orig_line_init",
47
+ " __agent_sh_line_init() {",
48
+ " zle __agent_sh_orig_line_init",
49
+ ` printf "\\e]9998;${instanceTag};READY\\a"`,
50
+ " }",
51
+ "else",
52
+ " __agent_sh_line_init() {",
53
+ ` printf "\\e]9998;${instanceTag};READY\\a"`,
54
+ " }",
55
+ "fi",
56
+ "zle -N zle-line-init __agent_sh_line_init",
57
+ "",
58
+ "# Hidden widget to trigger prompt redraw from Node.js side",
59
+ "# Bound to an unused escape sequence that no real key produces",
60
+ "__agent_sh_redraw() {",
61
+ " zle reset-prompt",
62
+ "}",
63
+ "zle -N __agent_sh_redraw",
64
+ "bindkey '\\e[9999~' __agent_sh_redraw",
65
+ ];
66
+
67
+ fs.writeFileSync(path.join(tmpDir, ".zshrc"), lines.join("\n") + "\n");
68
+
69
+ return {
70
+ args: ["--no-globalrcs"],
71
+ envOverrides: { ZDOTDIR: tmpDir },
72
+ tmpDir,
73
+ };
74
+ },
75
+
76
+ envCaptureCommand(): string {
77
+ return "source ~/.zshrc 2>/dev/null; env -0";
78
+ },
79
+
80
+ redrawEscape(): string {
81
+ return "\x1b[9999~";
82
+ },
83
+
84
+ cleanOutput(raw: string): string {
85
+ return raw.replace(PROMPT_SP_RE, "");
86
+ },
87
+ };
88
+
89
+ /** PROMPT_SP marker (inverse-video PROMPT_EOL_MARK, default `%`) zsh prints
90
+ * before a prompt when prior output didn't end at column 0. Matching the
91
+ * inverse-video wrapper preserves legitimate trailing `%`. */
92
+ const PROMPT_SP_RE = /\x1b\[7m.\x1b\[(?:0|27)m/g;
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Terminal — the user-facing I/O endpoint that a Shell talks to.
3
+ *
4
+ * Shell wraps a *pseudo*-terminal (the PTY the child shell sees). This
5
+ * interface is the *real* terminal (or its substitute) on the other end:
6
+ * bytes in, bytes out, dimensions, resize notifications. The default
7
+ * factory wires it to process.stdin/stdout for the CLI; headless hosts
8
+ * (multi-session web hubs, tests) supply their own.
9
+ */
10
+ import type { RenderSurface } from "../utils/compositor.js";
11
+
12
+ export interface Terminal {
13
+ write(data: string): void;
14
+ onInput(cb: (data: string) => void): () => void;
15
+ onResize(cb: (cols: number, rows: number) => void): () => void;
16
+ cols(): number;
17
+ rows(): number;
18
+ /**
19
+ * Called around PTY spawn to avoid TTY contention: the child PTY becomes
20
+ * the controlling tty for the spawned shell. No-op when the terminal
21
+ * isn't a real tty.
22
+ */
23
+ suspendInput?(): { resume(): void };
24
+ }
25
+
26
+ /** Default Terminal: wraps process.stdin/stdout. */
27
+ export function processTerminal(): Terminal {
28
+ return {
29
+ write(data) {
30
+ if (process.stdout.writable) {
31
+ try { process.stdout.write(data); } catch { /* ignore */ }
32
+ }
33
+ },
34
+ onInput(cb) {
35
+ const handler = (b: Buffer) => cb(b.toString("utf-8"));
36
+ process.stdin.on("data", handler);
37
+ return () => { process.stdin.off("data", handler); };
38
+ },
39
+ onResize(cb) {
40
+ const handler = () => cb(process.stdout.columns || 80, process.stdout.rows || 24);
41
+ process.stdout.on("resize", handler);
42
+ return () => { process.stdout.off("resize", handler); };
43
+ },
44
+ cols() { return process.stdout.columns || 80; },
45
+ rows() { return process.stdout.rows || 24; },
46
+ suspendInput() {
47
+ const wasRaw = process.stdin.isTTY && (process.stdin as { isRaw?: boolean }).isRaw;
48
+ if (process.stdin.isTTY) {
49
+ try {
50
+ process.stdin.setRawMode(false);
51
+ process.stdin.pause();
52
+ } catch { /* ignore */ }
53
+ }
54
+ return {
55
+ resume() {
56
+ if (process.stdin.isTTY) {
57
+ try {
58
+ process.stdin.resume();
59
+ if (wasRaw) process.stdin.setRawMode(true);
60
+ } catch { /* ignore */ }
61
+ }
62
+ },
63
+ };
64
+ },
65
+ };
66
+ }
67
+
68
+ /**
69
+ * No-op terminal for non-rendering hosts (tests, agent-only embeds).
70
+ * Writes are discarded; input/resize never fire.
71
+ */
72
+ export function headlessTerminal(cols = 100, rows = 30): Terminal {
73
+ return {
74
+ write() {},
75
+ onInput: () => () => {},
76
+ onResize: () => () => {},
77
+ cols: () => cols,
78
+ rows: () => rows,
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Pipe-based terminal for embedders that own their own renderer (web hubs
84
+ * via xterm.js, electron windows, recording harnesses). Bytes from the
85
+ * Shell flow through `onWrite`; the host drives `pushInput`/`pushResize`
86
+ * to forward keystrokes and viewport changes back.
87
+ */
88
+ export class BridgedTerminal implements Terminal {
89
+ private inputCbs = new Set<(d: string) => void>();
90
+ private resizeCbs = new Set<(c: number, r: number) => void>();
91
+ private _cols: number;
92
+ private _rows: number;
93
+ constructor(private readonly onWrite: (data: string) => void, cols = 100, rows = 30) {
94
+ this._cols = cols;
95
+ this._rows = rows;
96
+ }
97
+ write(data: string): void { this.onWrite(data); }
98
+ onInput(cb: (d: string) => void): () => void { this.inputCbs.add(cb); return () => { this.inputCbs.delete(cb); }; }
99
+ onResize(cb: (c: number, r: number) => void): () => void { this.resizeCbs.add(cb); return () => { this.resizeCbs.delete(cb); }; }
100
+ cols(): number { return this._cols; }
101
+ rows(): number { return this._rows; }
102
+ pushInput(data: string): void { for (const cb of this.inputCbs) cb(data); }
103
+ pushResize(cols: number, rows: number): void {
104
+ this._cols = cols;
105
+ this._rows = rows;
106
+ for (const cb of this.resizeCbs) cb(cols, rows);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Adapt a Terminal to a RenderSurface (the compositor's sink type). Adds
112
+ * the OPOST-cleared `\n` → `\r\n` translation that StdoutSurface applies,
113
+ * since the PTY has OPOST disabled.
114
+ */
115
+ export function surfaceFromTerminal(terminal: Terminal): RenderSurface {
116
+ const write = (text: string) => terminal.write(text.replace(/(?<!\r)\n/g, "\r\n"));
117
+ return {
118
+ write,
119
+ writeLine: (line) => write(line + "\n"),
120
+ get columns() { return terminal.cols(); },
121
+ get rows() { return terminal.rows(); },
122
+ onResize: (cb) => terminal.onResize(cb),
123
+ };
124
+ }
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Terminal renderer for the input-mode prompt and autocomplete dropdown.
3
+ * Owns screen state (cursor row/col, autocomplete line count) and the
4
+ * ANSI redraw. The controller drives it via a small VM shape.
5
+ */
6
+
7
+ import { visibleLen, truncateToWidth } from "../utils/ansi.js";
8
+ import { palette as p } from "../utils/palette.js";
9
+ import type { RenderSurface } from "../utils/compositor.js";
10
+ import { StdoutSurface } from "../utils/compositor.js";
11
+
12
+ export interface PromptVM {
13
+ showBuffer: boolean;
14
+ displayText: string;
15
+ displayCursor: number;
16
+ indicator: string;
17
+ promptIcon: string;
18
+ agentInfo: { info: string };
19
+ }
20
+
21
+ export interface AutocompleteVM {
22
+ items: { name: string; description: string }[];
23
+ selected: number;
24
+ }
25
+
26
+ export class TuiInputView {
27
+ private cursorRowsBelow = 0;
28
+ private cursorTermCol = 1;
29
+ private autocompleteLines = 0;
30
+ private readonly surface: RenderSurface;
31
+ private frameBuf: string | null = null;
32
+
33
+ constructor(surface?: RenderSurface) {
34
+ this.surface = surface ?? new StdoutSurface();
35
+ }
36
+
37
+ // Frame buffering: coalesces all emit() calls until endFrame() into one
38
+ // surface.write, bracketed by cursor hide/show so intermediate redraw
39
+ // states never flicker through.
40
+ beginFrame(): void {
41
+ if (this.frameBuf === null) this.frameBuf = "\x1b[?25l";
42
+ }
43
+
44
+ endFrame(): void {
45
+ if (this.frameBuf === null) return;
46
+ const out = this.frameBuf + "\x1b[?25h";
47
+ this.frameBuf = null;
48
+ this.surface.write(out);
49
+ }
50
+
51
+ private emit(s: string): void {
52
+ if (this.frameBuf !== null) this.frameBuf += s;
53
+ else this.surface.write(s);
54
+ }
55
+
56
+ private autoFrame<T>(fn: () => T): T {
57
+ const owned = this.frameBuf === null;
58
+ if (owned) this.beginFrame();
59
+ try { return fn(); } finally { if (owned) this.endFrame(); }
60
+ }
61
+
62
+ resetCursor(): void {
63
+ this.cursorRowsBelow = 0;
64
+ this.cursorTermCol = 1;
65
+ }
66
+
67
+ enableModeKeys(): void {
68
+ // Kitty progressive enhancement + bracket paste (Shift+Enter → \x1b[13;2u).
69
+ this.emit("\x1b[>1u\x1b[?2004h");
70
+ }
71
+
72
+ disableModeKeys(): void {
73
+ this.emit("\x1b[<u\x1b[?2004l");
74
+ }
75
+
76
+ clearPromptArea(): void {
77
+ this.autoFrame(() => {
78
+ if (this.cursorRowsBelow > 0) {
79
+ this.emit(`\x1b[${this.cursorRowsBelow}A`);
80
+ }
81
+ this.emit("\r\x1b[J");
82
+ this.cursorRowsBelow = 0;
83
+ });
84
+ }
85
+
86
+ drawPrompt(vm: PromptVM): void {
87
+ this.autoFrame(() => {
88
+ const termW = this.surface.columns;
89
+
90
+ if (this.cursorRowsBelow > 0) {
91
+ this.emit(`\x1b[${this.cursorRowsBelow}A`);
92
+ }
93
+ this.emit("\r\x1b[J");
94
+
95
+ const infoPrefix = vm.agentInfo.info
96
+ ? `${vm.agentInfo.info} ${p.success}${vm.indicator}${p.reset} `
97
+ : `${p.success}${vm.indicator}${p.reset} `;
98
+ const promptPrefix = infoPrefix + p.warning + p.bold + vm.promptIcon + " " + p.reset;
99
+ const promptVisLen = visibleLen(infoPrefix) + visibleLen(vm.promptIcon) + 1;
100
+
101
+ const display = vm.showBuffer ? vm.displayText : "";
102
+ const dCursor = vm.showBuffer ? vm.displayCursor : 0;
103
+
104
+ if (!vm.showBuffer) {
105
+ this.emit(promptPrefix);
106
+ const N = promptVisLen;
107
+ this.cursorRowsBelow = N > 0 ? Math.ceil(N / termW) - 1 : 0;
108
+ this.cursorTermCol = N === 0 ? 1 : (N % termW === 0 ? termW : (N % termW) + 1);
109
+ } else if (!display.includes("\n")) {
110
+ // DECSC/DECRC bracket the after-cursor text so the cursor lands mid-line.
111
+ const before = display.slice(0, dCursor);
112
+ const after = display.slice(dCursor);
113
+ this.emit(
114
+ promptPrefix + p.accent + before + p.reset +
115
+ "\x1b7" +
116
+ p.accent + after + p.reset +
117
+ "\x1b8"
118
+ );
119
+ const cursorVisCol = promptVisLen + visibleLen(before);
120
+ this.cursorRowsBelow = cursorVisCol > 0 ? Math.ceil(cursorVisCol / termW) - 1 : 0;
121
+ this.cursorTermCol = cursorVisCol === 0 ? 1 : (cursorVisCol % termW === 0 ? termW : (cursorVisCol % termW) + 1);
122
+ } else {
123
+ const lines = display.split("\n");
124
+ const indent = " ".repeat(promptVisLen);
125
+
126
+ let charsRemaining = dCursor;
127
+ let cursorLine = 0;
128
+ for (let li = 0; li < lines.length; li++) {
129
+ if (charsRemaining <= lines[li]!.length) {
130
+ cursorLine = li;
131
+ break;
132
+ }
133
+ charsRemaining -= lines[li]!.length + 1;
134
+ cursorLine = li + 1;
135
+ }
136
+
137
+ let output = "";
138
+ let cursorRowFromTop = 0;
139
+ let rowsSoFar = 0;
140
+
141
+ for (let li = 0; li < lines.length; li++) {
142
+ const prefix = li === 0 ? promptPrefix : indent;
143
+ const lineText = lines[li]!;
144
+ const lineVisLen = promptVisLen + visibleLen(lineText);
145
+ const lineTermRows = lineVisLen > 0 ? Math.ceil(lineVisLen / termW) : 1;
146
+
147
+ if (li === cursorLine) {
148
+ const before = lineText.slice(0, charsRemaining);
149
+ const after = lineText.slice(charsRemaining);
150
+ output += prefix + p.accent + before + p.reset;
151
+ output += "\x1b7";
152
+ output += p.accent + after + p.reset;
153
+
154
+ const beforeVisCol = promptVisLen + visibleLen(before);
155
+ cursorRowFromTop = rowsSoFar + (beforeVisCol > 0 ? Math.ceil(beforeVisCol / termW) - 1 : 0);
156
+ this.cursorTermCol = beforeVisCol === 0 ? 1 : (beforeVisCol % termW === 0 ? termW : (beforeVisCol % termW) + 1);
157
+ } else {
158
+ output += prefix + p.accent + lineText + p.reset;
159
+ }
160
+
161
+ if (li < lines.length - 1) output += "\n";
162
+ rowsSoFar += lineTermRows;
163
+ }
164
+
165
+ this.emit(output + "\x1b8");
166
+ this.cursorRowsBelow = cursorRowFromTop;
167
+ }
168
+ });
169
+ }
170
+
171
+ drawAutocomplete(vm: AutocompleteVM): void {
172
+ if (vm.items.length === 0) return;
173
+ this.autoFrame(() => {
174
+ // Truncate descriptions so each row fits one physical line — a wrapped
175
+ // row makes autocompleteLines undercount, clearAutocomplete leaves a
176
+ // residual row, and the next drawPrompt redraws below the original
177
+ // prompt (staircase).
178
+ const termW = this.surface.columns;
179
+ const lines: string[] = [];
180
+ for (let i = 0; i < vm.items.length; i++) {
181
+ const item = vm.items[i]!;
182
+ const nameW = Math.max(12, visibleLen(item.name));
183
+ const overhead = 5 + nameW;
184
+ const descBudget = Math.max(1, termW - overhead);
185
+ const desc = visibleLen(item.description) > descBudget
186
+ ? truncateToWidth(item.description, descBudget)
187
+ : item.description;
188
+ const selected = i === vm.selected;
189
+ if (selected) {
190
+ lines.push(
191
+ ` \x1b[7m ${p.accent}${item.name.padEnd(12)}${p.reset}\x1b[7m ${desc} ${p.reset}`
192
+ );
193
+ } else {
194
+ lines.push(
195
+ ` ${p.muted}${item.name.padEnd(12)} ${desc}${p.reset}`
196
+ );
197
+ }
198
+ }
199
+
200
+ this.emit("\n" + lines.join("\n"));
201
+ this.autocompleteLines = lines.length;
202
+
203
+ if (this.autocompleteLines > 0) {
204
+ this.emit(`\x1b[${this.autocompleteLines}A`);
205
+ }
206
+ // Absolute column set — preceding \n may have scrolled, invalidating DECSC.
207
+ this.emit(`\x1b[${this.cursorTermCol}G`);
208
+ });
209
+ }
210
+
211
+ clearAutocomplete(): void {
212
+ if (this.autocompleteLines <= 0) return;
213
+ this.autoFrame(() => {
214
+ // CSI B (cursor down, bounded) so we don't scroll on the last row.
215
+ for (let i = 0; i < this.autocompleteLines; i++) {
216
+ this.emit("\x1b[B\x1b[2K");
217
+ }
218
+ this.emit(`\x1b[${this.autocompleteLines}A\x1b[${this.cursorTermCol}G`);
219
+ this.autocompleteLines = 0;
220
+ });
221
+ }
222
+ }