agent-sh 0.14.11 → 0.15.1

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 (174) hide show
  1. package/README.md +38 -42
  2. package/dist/agent/agent-loop.d.ts +9 -17
  3. package/dist/agent/agent-loop.js +104 -136
  4. package/dist/agent/events.d.ts +8 -11
  5. package/dist/agent/host-types.d.ts +17 -11
  6. package/dist/agent/index.d.ts +1 -1
  7. package/dist/agent/index.js +38 -22
  8. package/dist/agent/providers/deepseek.js +9 -1
  9. package/dist/agent/session-store.js +1 -1
  10. package/dist/agent/system-prompt.d.ts +7 -3
  11. package/dist/agent/system-prompt.js +11 -14
  12. package/dist/agent/tool-protocol.js +0 -7
  13. package/dist/cli/args.js +2 -1
  14. package/dist/cli/install.d.ts +1 -0
  15. package/dist/cli/install.js +29 -1
  16. package/dist/cli/subcommands.js +1 -0
  17. package/dist/core/event-bus.js +0 -2
  18. package/dist/core/extension-loader.js +3 -1
  19. package/dist/core/index.d.ts +1 -1
  20. package/dist/core/index.js +3 -2
  21. package/dist/extensions/slash-commands/index.js +16 -11
  22. package/dist/shell/index.js +9 -0
  23. package/dist/shell/shell-context.d.ts +2 -2
  24. package/dist/shell/shell-context.js +26 -11
  25. package/dist/shell/tui-renderer.js +0 -1
  26. package/dist/utils/diff-renderer.js +2 -9
  27. package/dist/utils/handler-registry.d.ts +1 -6
  28. package/dist/utils/handler-registry.js +1 -6
  29. package/dist/utils/line-editor.js +0 -2
  30. package/dist/utils/palette.js +4 -4
  31. package/dist/utils/terminal-buffer.d.ts +2 -0
  32. package/dist/utils/terminal-buffer.js +4 -0
  33. package/docs/README.md +14 -0
  34. package/docs/agent.md +398 -0
  35. package/docs/architecture.md +196 -0
  36. package/docs/context-management.md +200 -0
  37. package/docs/extensions.md +951 -0
  38. package/docs/library.md +84 -0
  39. package/docs/troubleshooting.md +65 -0
  40. package/docs/tui-composition.md +294 -0
  41. package/docs/usage.md +306 -0
  42. package/examples/extensions/ash-acp-bridge/src/index.ts +11 -7
  43. package/examples/extensions/ash-scheme/index.ts +104 -74
  44. package/examples/extensions/ash-scheme/package.json +1 -1
  45. package/examples/extensions/ashi/EXTENDING.md +4 -2
  46. package/examples/extensions/ashi/README.md +17 -1
  47. package/examples/extensions/ashi/docs/ui-surface-protocol.md +163 -0
  48. package/examples/extensions/ashi/package.json +13 -3
  49. package/examples/extensions/ashi/src/capture.ts +45 -7
  50. package/examples/extensions/ashi/src/chat/assistant.ts +23 -43
  51. package/examples/extensions/ashi/src/chat/lines.ts +20 -1
  52. package/examples/extensions/ashi/src/cli.ts +26 -3
  53. package/examples/extensions/ashi/src/clipboard-image.ts +1 -1
  54. package/examples/extensions/ashi/src/dialogs.ts +67 -0
  55. package/examples/extensions/ashi/src/display-config.ts +7 -0
  56. package/examples/extensions/ashi/src/docks.ts +31 -0
  57. package/examples/extensions/ashi/src/events.ts +16 -0
  58. package/examples/extensions/ashi/src/frontend.ts +134 -27
  59. package/examples/extensions/ashi/src/hooks.ts +6 -12
  60. package/examples/extensions/ashi/src/input-prompt.ts +64 -0
  61. package/examples/extensions/ashi/src/renderer.ts +22 -2
  62. package/examples/extensions/ashi/src/renderers/pi-tui/index.ts +7 -3
  63. package/examples/extensions/ashi/src/renderers/pi-tui/nodes.ts +67 -10
  64. package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +11 -1
  65. package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +5 -8
  66. package/examples/extensions/ashi/src/schema.ts +3 -0
  67. package/examples/extensions/ashi/src/session-commands.ts +2 -1
  68. package/examples/extensions/ashi/src/status-footer.ts +21 -3
  69. package/examples/extensions/ashi/src/ui.ts +88 -0
  70. package/examples/extensions/ashi-ink/README.md +2 -0
  71. package/examples/extensions/ashi-ink/package.json +2 -2
  72. package/examples/extensions/ashi-scheme-render.ts +8 -2
  73. package/examples/extensions/ashi-ui-demo.ts +63 -0
  74. package/examples/extensions/claude-code-bridge/package.json +1 -1
  75. package/examples/extensions/latex-images.ts +57 -9
  76. package/examples/extensions/opencode-bridge/package.json +1 -1
  77. package/examples/extensions/overlay-agent.ts +5 -5
  78. package/examples/extensions/pi-bridge/index.ts +7 -12
  79. package/package.json +3 -1
  80. package/src/agent/agent-loop.ts +1563 -0
  81. package/src/agent/entry-format.ts +19 -0
  82. package/src/agent/events.ts +151 -0
  83. package/src/agent/extensions/rolling-history/constants.ts +1 -0
  84. package/src/agent/extensions/rolling-history/index.ts +202 -0
  85. package/src/agent/extensions/rolling-history/recall.ts +131 -0
  86. package/src/agent/extensions/rolling-history/strategy.ts +404 -0
  87. package/src/agent/host-types.ts +192 -0
  88. package/src/agent/index.ts +591 -0
  89. package/src/agent/live-view.ts +279 -0
  90. package/src/agent/llm-client.ts +111 -0
  91. package/src/agent/llm-facade.ts +43 -0
  92. package/src/agent/normalize-args.ts +61 -0
  93. package/src/agent/nuclear-form.ts +382 -0
  94. package/src/agent/providers/deepseek.ts +39 -0
  95. package/src/agent/providers/ollama.ts +92 -0
  96. package/src/agent/providers/openai-compatible.ts +36 -0
  97. package/src/agent/providers/openai.ts +52 -0
  98. package/src/agent/providers/opencode.ts +142 -0
  99. package/src/agent/providers/openrouter.ts +105 -0
  100. package/src/agent/providers/zai-coding-plan.ts +33 -0
  101. package/src/agent/session-store.ts +336 -0
  102. package/src/agent/skills.ts +228 -0
  103. package/src/agent/store.ts +310 -0
  104. package/src/agent/subagent.ts +305 -0
  105. package/src/agent/system-prompt.ts +151 -0
  106. package/src/agent/token-budget.ts +12 -0
  107. package/src/agent/tool-protocol.ts +722 -0
  108. package/src/agent/tool-registry.ts +66 -0
  109. package/src/agent/tools/bash.ts +95 -0
  110. package/src/agent/tools/edit-file.ts +154 -0
  111. package/src/agent/tools/expand-home.ts +7 -0
  112. package/src/agent/tools/glob.ts +108 -0
  113. package/src/agent/tools/grep.ts +228 -0
  114. package/src/agent/tools/list-skills.ts +37 -0
  115. package/src/agent/tools/ls.ts +81 -0
  116. package/src/agent/tools/pwsh.ts +140 -0
  117. package/src/agent/tools/read-file.ts +164 -0
  118. package/src/agent/tools/write-file.ts +72 -0
  119. package/src/agent/types.ts +149 -0
  120. package/src/cli/args.ts +91 -0
  121. package/src/cli/auth/cli.ts +244 -0
  122. package/src/cli/auth/discover.ts +52 -0
  123. package/src/cli/auth/keys.ts +143 -0
  124. package/src/cli/index.ts +295 -0
  125. package/src/cli/init.ts +74 -0
  126. package/src/cli/install.ts +439 -0
  127. package/src/cli/shell-env.ts +68 -0
  128. package/src/cli/subcommands.ts +24 -0
  129. package/src/core/event-bus.ts +252 -0
  130. package/src/core/extension-loader.ts +347 -0
  131. package/src/core/index.ts +152 -0
  132. package/src/core/settings.ts +398 -0
  133. package/src/core/types.ts +61 -0
  134. package/src/extensions/file-autocomplete.ts +71 -0
  135. package/src/extensions/index.ts +38 -0
  136. package/src/extensions/slash-commands/events.ts +14 -0
  137. package/src/extensions/slash-commands/index.ts +269 -0
  138. package/src/shell/events.ts +73 -0
  139. package/src/shell/host-types.ts +150 -0
  140. package/src/shell/index.ts +159 -0
  141. package/src/shell/input-handler.ts +505 -0
  142. package/src/shell/output-parser.ts +156 -0
  143. package/src/shell/shell-context.ts +193 -0
  144. package/src/shell/shell.ts +414 -0
  145. package/src/shell/strategies/bash.ts +83 -0
  146. package/src/shell/strategies/fish.ts +77 -0
  147. package/src/shell/strategies/index.ts +24 -0
  148. package/src/shell/strategies/types.ts +64 -0
  149. package/src/shell/strategies/zsh.ts +92 -0
  150. package/src/shell/terminal.ts +124 -0
  151. package/src/shell/tui-input-view.ts +222 -0
  152. package/src/shell/tui-renderer.ts +1126 -0
  153. package/src/utils/ansi.ts +140 -0
  154. package/src/utils/box-frame.ts +138 -0
  155. package/src/utils/compositor.ts +157 -0
  156. package/src/utils/diff-renderer.ts +829 -0
  157. package/src/utils/diff.ts +244 -0
  158. package/src/utils/executor.ts +305 -0
  159. package/src/utils/file-watcher.ts +110 -0
  160. package/src/utils/floating-panel.ts +1160 -0
  161. package/src/utils/handler-registry.ts +110 -0
  162. package/src/utils/line-editor.ts +636 -0
  163. package/src/utils/markdown.ts +437 -0
  164. package/src/utils/message-utils.ts +113 -0
  165. package/src/utils/package-version.ts +12 -0
  166. package/src/utils/palette.ts +64 -0
  167. package/src/utils/ref-counter.ts +9 -0
  168. package/src/utils/ripgrep-path.ts +17 -0
  169. package/src/utils/shell-output-spill.ts +76 -0
  170. package/src/utils/stream-transform.ts +292 -0
  171. package/src/utils/terminal-buffer.ts +213 -0
  172. package/src/utils/tool-display.ts +315 -0
  173. package/src/utils/tool-interactive.ts +71 -0
  174. package/src/utils/tty.ts +14 -0
@@ -0,0 +1,1160 @@
1
+ /**
2
+ * Floating panel utility for overlay extensions.
3
+ *
4
+ * Provides a composited floating box rendered over the terminal using
5
+ * an alternate screen buffer. Handles the full overlay lifecycle:
6
+ * stdout hold/release, input routing, compositing, scroll, and
7
+ * screen restore.
8
+ *
9
+ * Rendering is fully customizable via the handler/advise pattern:
10
+ *
11
+ * // Replace the entire frame renderer
12
+ * panel.handlers.define("panel:render-frame", (ctx) => {
13
+ * // ctx has geo, content, bgLines, phase, title, footer, border
14
+ * return { rows: myCustomRows, cursorSeq: "" };
15
+ * });
16
+ *
17
+ * // Or advise individual pieces
18
+ * panel.handlers.advise("panel:render-border-top", (next, ctx) => {
19
+ * return `┏━ ${ctx.title} ${"━".repeat(ctx.geo.boxW - ctx.title.length - 5)}┓`;
20
+ * });
21
+ *
22
+ * panel.handlers.advise("panel:composite-row", (next, boxLine, bgLine, ...) => {
23
+ * // custom compositing (e.g. no dimming, blur effect, etc.)
24
+ * return next(boxLine, bgLine, ...);
25
+ * });
26
+ *
27
+ * When @xterm/headless is needed (for dimmed background compositing):
28
+ * npm install @xterm/headless@5.5.0 @xterm/addon-serialize@0.13.0
29
+ *
30
+ * Usage from extensions:
31
+ * import { FloatingPanel } from "agent-sh/utils/floating-panel.js";
32
+ */
33
+ import { stripAnsi } from "./ansi.js";
34
+ import { wrapLine } from "./markdown.js";
35
+ import { LineEditor } from "./line-editor.js";
36
+ import { TerminalBuffer } from "./terminal-buffer.js";
37
+ import { HandlerRegistry } from "./handler-registry.js";
38
+ import type { EventBus } from "../core/event-bus.js";
39
+ import type { BorderStyle } from "./box-frame.js";
40
+ import { StdoutSurface, type RenderSurface } from "./compositor.js";
41
+
42
+ // ── ANSI constants ──────────────────────────────────────────────
43
+
44
+ const DIM = "\x1b[2m";
45
+ const RESET = "\x1b[0m";
46
+ const INVERSE = "\x1b[7m";
47
+ const SYNC_START = "\x1b[?2026h";
48
+ const SYNC_END = "\x1b[?2026l";
49
+
50
+ // ── Border characters ───────────────────────────────────────────
51
+
52
+ const BORDERS: Record<BorderStyle, { tl: string; tr: string; bl: string; br: string; h: string; v: string }> = {
53
+ rounded: { tl: "\u256d", tr: "\u256e", bl: "\u2570", br: "\u256f", h: "\u2500", v: "\u2502" },
54
+ square: { tl: "\u250c", tr: "\u2510", bl: "\u2514", br: "\u2518", h: "\u2500", v: "\u2502" },
55
+ double: { tl: "\u2554", tr: "\u2557", bl: "\u255a", br: "\u255d", h: "\u2550", v: "\u2551" },
56
+ heavy: { tl: "\u250f", tr: "\u2513", bl: "\u2517", br: "\u251b", h: "\u2501", v: "\u2503" },
57
+ };
58
+
59
+ // ── Trigger sequence helpers ────────────────────────────────────
60
+ // Programs like vim enable xterm's modifyOtherKeys or the kitty
61
+ // keyboard protocol, which encode Ctrl+key as CSI sequences instead
62
+ // of raw control bytes. We pre-compute every encoding of the
63
+ // trigger so it works regardless of what the foreground process has
64
+ // negotiated with the terminal.
65
+
66
+ function buildTriggerSequences(trigger: string): string[] {
67
+ const seqs = [trigger];
68
+ if (trigger.length === 1) {
69
+ const code = trigger.charCodeAt(0);
70
+ if (code < 32) {
71
+ // Ctrl+key: base codepoint is code | 0x40 (e.g. 0x1c → 0x5c = '\')
72
+ const base = code | 0x40;
73
+ // xterm modifyOtherKeys mode 2: ESC[27;5;<base>~
74
+ seqs.push(`\x1b[27;5;${base}~`);
75
+ // kitty keyboard protocol: ESC[<base>;5u
76
+ seqs.push(`\x1b[${base};5u`);
77
+ }
78
+ }
79
+ return seqs;
80
+ }
81
+
82
+ // ── Types ───────────────────────────────────────────────────────
83
+
84
+ export interface FloatingPanelConfig {
85
+ /** Key sequence that toggles the panel (e.g. "\x1c" for Ctrl+\). */
86
+ trigger: string;
87
+ /** Panel width. Number = columns, string with % = percentage. Default: "80%". */
88
+ width?: number | string;
89
+ /** Max width in columns. Default: 100. */
90
+ maxWidth?: number;
91
+ /** Panel height. Number = rows, string with % = percentage. Default: "60%". */
92
+ height?: number | string;
93
+ /** Min content rows inside the panel. Default: 6. */
94
+ minHeight?: number;
95
+ /** Border style. Default: "rounded". */
96
+ borderStyle?: BorderStyle;
97
+ /**
98
+ * Show dimmed terminal content behind the panel. Default: true.
99
+ * Requires @xterm/headless — falls back to blank background if unavailable.
100
+ */
101
+ dimBackground?: boolean;
102
+ /** Auto-dismiss delay in ms when done (0 = auto-prompt for follow-up). Default: 0. */
103
+ autoDismissMs?: number;
104
+ /** Icon shown before the input cursor. Default: "\u276f". */
105
+ promptIcon?: string;
106
+ /**
107
+ * Pre-existing TerminalBuffer to reuse. If provided, the panel will
108
+ * not create its own headless terminal. Useful when sharing a buffer
109
+ * with other features (e.g. context injection, terminal_read tool).
110
+ */
111
+ terminalBuffer?: TerminalBuffer;
112
+ /**
113
+ * Handler namespace prefix. Default: "panel".
114
+ * All handlers are registered as `{prefix}:render-content`,
115
+ * `{prefix}:submit`, etc. Use different prefixes for multiple panels.
116
+ */
117
+ handlerPrefix?: string;
118
+ /** Render sink + viewport. Defaults to a fresh StdoutSurface. */
119
+ surface?: RenderSurface;
120
+ }
121
+
122
+ /**
123
+ * Context passed to the render-content handler.
124
+ */
125
+ export interface RenderContext {
126
+ /** Available width for content (inside box, excluding borders and padding). */
127
+ width: number;
128
+ /** Available height for content (rows inside box). */
129
+ height: number;
130
+ /** Current panel phase. */
131
+ phase: Phase;
132
+ /** Current input buffer text (during input phase). */
133
+ inputBuffer: string;
134
+ /** Current input cursor position (during input phase). */
135
+ inputCursor: number;
136
+ /** Current scroll offset. */
137
+ scrollOffset: number;
138
+ /** Built-in content lines (from appendText/appendLine). */
139
+ contentLines: readonly string[];
140
+ /** Current partial line being streamed. */
141
+ partialLine: string;
142
+ }
143
+
144
+ /**
145
+ * Result from render-content handler.
146
+ */
147
+ export interface RenderResult {
148
+ lines: string[];
149
+ /** Optional cursor position within the content area. */
150
+ cursor?: { row: number; col: number };
151
+ }
152
+
153
+ /**
154
+ * Box geometry computed from config + terminal size.
155
+ */
156
+ export interface BoxGeometry {
157
+ /** Terminal columns. */
158
+ cols: number;
159
+ /** Terminal rows. */
160
+ rows: number;
161
+ /** Box width in columns (including borders). */
162
+ boxW: number;
163
+ /** Box height in rows (including borders). */
164
+ boxH: number;
165
+ /** Box top offset (0-indexed row). */
166
+ boxTop: number;
167
+ /** Box left offset (0-indexed column). */
168
+ boxLeft: number;
169
+ /** Usable content width inside box. */
170
+ contentW: number;
171
+ /** Usable content height inside box. */
172
+ contentH: number;
173
+ }
174
+
175
+ /**
176
+ * Context passed to the render-frame handler.
177
+ */
178
+ export interface FrameContext {
179
+ /** Box geometry. */
180
+ geo: BoxGeometry;
181
+ /** Content render result (from render-content handler). */
182
+ content: RenderResult;
183
+ /** Background lines from the terminal buffer (null if no dimming). */
184
+ bgLines: string[] | null;
185
+ /** Current panel phase. */
186
+ phase: Phase;
187
+ /** Current title text. */
188
+ title: string;
189
+ /** Current footer text. */
190
+ footer: string;
191
+ /** Border characters for the configured border style. */
192
+ border: { tl: string; tr: string; bl: string; br: string; h: string; v: string };
193
+ }
194
+
195
+ /**
196
+ * Result from render-frame handler.
197
+ */
198
+ export interface FrameResult {
199
+ /** One string per terminal row. */
200
+ rows: string[];
201
+ /** ANSI sequence to position the cursor (empty string if no cursor). */
202
+ cursorSeq: string;
203
+ }
204
+
205
+ export type Phase = "idle" | "input" | "active" | "done";
206
+
207
+ // ── FloatingPanel ───────────────────────────────────────────────
208
+
209
+ export class FloatingPanel {
210
+ // ── Configuration ───────────────────────────────────────────
211
+ private readonly config: Required<Omit<FloatingPanelConfig, "terminalBuffer" | "surface">>;
212
+ private readonly bus: EventBus;
213
+ private readonly surface: RenderSurface;
214
+ private readonly border: { tl: string; tr: string; bl: string; br: string; h: string; v: string };
215
+ private readonly externalBuffer: TerminalBuffer | undefined;
216
+ private readonly prefix: string;
217
+
218
+ /**
219
+ * Handler registry for this panel. Extensions use `handlers.advise()`
220
+ * to customize rendering and behavior.
221
+ *
222
+ * Registered handlers:
223
+ * - `{prefix}:render-content(ctx: RenderContext) -> RenderResult`
224
+ * - `{prefix}:render-frame(ctx: FrameContext) -> FrameResult`
225
+ * - `{prefix}:render-border-top(ctx: FrameContext) -> string`
226
+ * - `{prefix}:render-border-bottom(ctx: FrameContext) -> string`
227
+ * - `{prefix}:composite-row(content: string, bgLine: string|null, boxLeft: number, boxW: number, cols: number) -> string`
228
+ * - `{prefix}:submit(query: string) -> void`
229
+ * - `{prefix}:hide() -> void` (screen down; conversation state preserved)
230
+ * - `{prefix}:reset() -> void` (conversation state cleared)
231
+ * - `{prefix}:show() -> void`
232
+ * - `{prefix}:input(data: string) -> boolean`
233
+ * - `{prefix}:build-row(content: string, width: number) -> string`
234
+ */
235
+ readonly handlers: HandlerRegistry;
236
+
237
+ // ── Headless terminal (lazy, optional) ──────────────────────
238
+ private buffer: TerminalBuffer | null = null;
239
+ private bufferInitialized = false;
240
+
241
+ // ── Trigger sequences ───────────────────────────────────────
242
+ /** All byte sequences that should be recognized as the trigger key. */
243
+ private readonly triggerSeqs: string[];
244
+
245
+ // ── State ───────────────────────────────────────────────────
246
+ private phase: Phase = "idle";
247
+ private _visible = false; // whether the panel box is shown on screen
248
+ private _passthrough = false; // hidden but still rendering TerminalBuffer
249
+ private editor = new LineEditor();
250
+ private contentLines: string[] = [];
251
+ private currentPartialLine = "";
252
+ private scrollOffset = 0;
253
+ private userScrolled = false; // true when user manually scrolled away from bottom
254
+ private title = "";
255
+ private footer = "";
256
+ private renderTimer: ReturnType<typeof setTimeout> | null = null;
257
+ private resizeUnsub: (() => void) | null = null;
258
+ private prevFrame: string[] = [];
259
+ private suppressNextRedraw = false;
260
+ private autoDismissTimer: ReturnType<typeof setTimeout> | null = null;
261
+ private usedAltScreen = false; // whether we entered our own alt screen
262
+ private wrapCache = new Map<string, string[]>(); // line → wrapped lines (invalidated on width change)
263
+ private wrapCacheWidth = 0;
264
+ private passthroughTimer: ReturnType<typeof setInterval> | null = null;
265
+ private prevSerialized = "";
266
+
267
+ // ── Autocomplete ────────────────────────────────────────────
268
+ private autocompleteItems: { name: string; description: string }[] = [];
269
+ private autocompleteIndex = 0;
270
+ private autocompleteActive = false;
271
+
272
+ constructor(bus: EventBus, config: FloatingPanelConfig, handlers?: HandlerRegistry) {
273
+ this.bus = bus;
274
+ this.surface = config.surface ?? new StdoutSurface();
275
+ this.externalBuffer = config.terminalBuffer;
276
+ this.prefix = config.handlerPrefix ?? "panel";
277
+ this.handlers = handlers ?? new HandlerRegistry();
278
+ this.config = {
279
+ trigger: config.trigger,
280
+ width: config.width ?? "80%",
281
+ maxWidth: config.maxWidth ?? 100,
282
+ height: config.height ?? "60%",
283
+ minHeight: config.minHeight ?? 6,
284
+ borderStyle: config.borderStyle ?? "rounded",
285
+ dimBackground: config.dimBackground ?? true,
286
+ autoDismissMs: config.autoDismissMs ?? 0,
287
+ promptIcon: config.promptIcon ?? "\u276f",
288
+ handlerPrefix: this.prefix,
289
+ };
290
+ this.border = BORDERS[this.config.borderStyle];
291
+ this.triggerSeqs = buildTriggerSequences(config.trigger);
292
+
293
+ this.registerDefaultHandlers();
294
+ this.wireEvents();
295
+ }
296
+
297
+ // ── Default handler registration ───────────────────────────
298
+
299
+ private registerDefaultHandlers(): void {
300
+ const p = this.prefix;
301
+
302
+ // Default content renderer: uses built-in appendText/appendLine buffer
303
+ this.handlers.define(`${p}:render-content`, (ctx: RenderContext): RenderResult => {
304
+ const raw = [...ctx.contentLines, ...(ctx.partialLine ? [ctx.partialLine] : [])];
305
+
306
+ // Invalidate wrap cache if width changed
307
+ if (ctx.width !== this.wrapCacheWidth) {
308
+ this.wrapCache.clear();
309
+ this.wrapCacheWidth = ctx.width;
310
+ }
311
+
312
+ const all: string[] = [];
313
+ for (const line of raw) {
314
+ let wrapped = this.wrapCache.get(line);
315
+ if (!wrapped) {
316
+ wrapped = wrapLine(line, ctx.width);
317
+ this.wrapCache.set(line, wrapped);
318
+ }
319
+ all.push(...wrapped);
320
+ }
321
+
322
+ if (ctx.phase === "input" && this.autocompleteActive && this.autocompleteItems.length > 0) {
323
+ const ACMax = 5;
324
+ const items = this.autocompleteItems;
325
+ let acStart = 0;
326
+ let acEnd = items.length;
327
+ if (items.length > ACMax) {
328
+ acStart = Math.max(0, this.autocompleteIndex - Math.floor(ACMax / 2));
329
+ acStart = Math.min(acStart, items.length - ACMax);
330
+ acEnd = acStart + ACMax;
331
+ }
332
+ for (let i = acStart; i < acEnd; i++) {
333
+ const item = items[i]!;
334
+ const selected = i === this.autocompleteIndex;
335
+ const desc = item.description ? ` ${item.description}` : "";
336
+ if (selected) {
337
+ all.push(`${INVERSE} ${item.name}${desc} ${RESET}`);
338
+ } else {
339
+ all.push(` ${item.name}${DIM}${desc}${RESET}`);
340
+ }
341
+ }
342
+ }
343
+
344
+ let promptStartIdx = -1;
345
+ let cursorRowOffset = 0;
346
+ let cursorCol = 0;
347
+ if (ctx.phase === "input") {
348
+ const w = ctx.width;
349
+ const prefixLen = this.config.promptIcon.length + 1;
350
+ const styledPrefix = `\x1b[36m${this.config.promptIcon}${RESET} `;
351
+ const input = ctx.inputBuffer;
352
+ const firstLineCap = Math.max(1, w - prefixLen);
353
+ promptStartIdx = all.length;
354
+
355
+ if (input.length === 0) {
356
+ all.push(styledPrefix);
357
+ } else {
358
+ all.push(styledPrefix + input.slice(0, firstLineCap));
359
+ for (let i = firstLineCap; i < input.length; i += w) {
360
+ all.push(input.slice(i, i + w));
361
+ }
362
+ }
363
+
364
+ const cursorVp = prefixLen + ctx.inputCursor;
365
+ cursorRowOffset = Math.floor(cursorVp / w);
366
+ cursorCol = cursorVp % w;
367
+
368
+ // Cursor on an exact wrap boundary lands past the last rendered row.
369
+ while (all.length - promptStartIdx <= cursorRowOffset) {
370
+ all.push("");
371
+ }
372
+ }
373
+
374
+ // Scroll: auto-scroll to bottom unless user manually scrolled
375
+ let offset = ctx.scrollOffset;
376
+ const maxOffset = Math.max(0, all.length - ctx.height);
377
+ if (this.userScrolled) {
378
+ offset = Math.min(offset, maxOffset);
379
+ // Resume auto-scroll if user scrolled back to bottom
380
+ if (offset >= maxOffset) this.userScrolled = false;
381
+ } else {
382
+ offset = maxOffset;
383
+ }
384
+ this.scrollOffset = offset;
385
+
386
+ const visible = all.slice(offset, offset + ctx.height);
387
+
388
+ if (ctx.phase === "input" && promptStartIdx >= 0) {
389
+ const cursorRowInVisible = promptStartIdx + cursorRowOffset - offset;
390
+ if (cursorRowInVisible >= 0 && cursorRowInVisible < visible.length) {
391
+ return {
392
+ lines: visible,
393
+ cursor: { row: cursorRowInVisible, col: cursorCol },
394
+ };
395
+ }
396
+ }
397
+
398
+ return { lines: visible };
399
+ });
400
+
401
+ this.handlers.define(`${p}:submit`, (_query: string) => {});
402
+ this.handlers.define(`${p}:hide`, () => {});
403
+ this.handlers.define(`${p}:reset`, () => {});
404
+ this.handlers.define(`${p}:show`, () => {});
405
+
406
+ // Default custom input handler: don't consume
407
+ this.handlers.define(`${p}:input`, (_data: string): boolean => false);
408
+
409
+ // Default row builder: truncate and pad
410
+ this.handlers.define(`${p}:build-row`, (content: string, width: number): string => {
411
+ const plain = stripAnsi(content);
412
+ const display = plain.length > width
413
+ ? content.slice(0, width - 1) + "\u2026"
414
+ : content;
415
+ const pad = Math.max(0, width - stripAnsi(display).length);
416
+ return display + " ".repeat(pad);
417
+ });
418
+
419
+ // Default border-top renderer
420
+ this.handlers.define(`${p}:render-border-top`, (ctx: FrameContext): string => {
421
+ const { geo, border: b } = ctx;
422
+ const titleText = ctx.title || (ctx.phase === "input" ? "input" : ctx.phase === "done" ? "done" : "...");
423
+ const titleStr = ` ${INVERSE} ${titleText} ${RESET} `;
424
+ const titleVisLen = titleText.length + 4;
425
+ const dashCount = Math.max(0, geo.boxW - titleVisLen - 3);
426
+ return `${b.tl}${b.h}${titleStr}${b.h.repeat(dashCount)}${b.tr}`;
427
+ });
428
+
429
+ // Default border-bottom renderer
430
+ this.handlers.define(`${p}:render-border-bottom`, (ctx: FrameContext): string => {
431
+ const { geo, border: b } = ctx;
432
+ if (ctx.footer) {
433
+ const visLen = stripAnsi(ctx.footer).length;
434
+ const footerPad = Math.max(0, geo.boxW - visLen - 3);
435
+ return `${b.bl}${b.h.repeat(footerPad)}${DIM}${ctx.footer}${RESET}${b.h}${b.br}`;
436
+ }
437
+ return `${b.bl}${b.h.repeat(geo.boxW - 2)}${b.br}`;
438
+ });
439
+
440
+ // Default composite-row: merge content on top of dimmed background
441
+ this.handlers.define(`${p}:composite-row`, (
442
+ boxLine: string, bgLine: string | null, boxLeft: number, boxW: number, cols: number,
443
+ ): string => {
444
+ if (bgLine !== null) {
445
+ const bg = bgLine.padEnd(cols);
446
+ return `${DIM}${bg.slice(0, boxLeft)}${RESET}${boxLine}${DIM}${bg.slice(boxLeft + boxW)}${RESET}`;
447
+ }
448
+ return boxLine;
449
+ });
450
+
451
+ // Default frame renderer: assembles borders, content rows, and background
452
+ this.handlers.define(`${p}:render-frame`, (ctx: FrameContext): FrameResult => {
453
+ const { geo, content, bgLines, border: b } = ctx;
454
+ const visibleContent = [...(content.lines ?? [])];
455
+ while (visibleContent.length < geo.contentH) visibleContent.push("");
456
+
457
+ const composite = (boxLine: string, bg: string | null): string =>
458
+ this.handlers.call(`${p}:composite-row`, boxLine, bg, geo.boxLeft, geo.boxW, geo.cols);
459
+
460
+ const buildRow = (c: string, w: number): string =>
461
+ this.handlers.call(`${p}:build-row`, c, w);
462
+
463
+ const frame: string[] = [];
464
+ for (let row = 0; row < geo.rows; row++) {
465
+ const relRow = row - geo.boxTop;
466
+ const bg = bgLines?.[row] ?? null;
467
+
468
+ if (relRow < 0 || relRow >= geo.boxH) {
469
+ // Outside box
470
+ if (bgLines) {
471
+ frame.push(`${DIM}${(bgLines[row] || "").padEnd(geo.cols).slice(0, geo.cols)}${RESET}\x1b[K`);
472
+ } else {
473
+ frame.push("\x1b[2K");
474
+ }
475
+ } else if (relRow === 0) {
476
+ frame.push(composite(this.handlers.call(`${p}:render-border-top`, ctx), bg));
477
+ } else if (relRow === geo.boxH - 1) {
478
+ frame.push(composite(this.handlers.call(`${p}:render-border-bottom`, ctx), bg));
479
+ } else {
480
+ const raw = visibleContent[relRow - 1] || "";
481
+ const boxLine = `${b.v} ${buildRow(raw, geo.contentW)} ${b.v}`;
482
+ frame.push(composite(boxLine, bg));
483
+ }
484
+ }
485
+
486
+ let cursorSeq = "";
487
+ if (content.cursor) {
488
+ const cursorRow = geo.boxTop + 1 + content.cursor.row;
489
+ const cursorCol = geo.boxLeft + 2 + content.cursor.col;
490
+ cursorSeq = `\x1b[${cursorRow + 1};${cursorCol + 1}H`;
491
+ }
492
+
493
+ return { rows: frame, cursorSeq };
494
+ });
495
+ }
496
+
497
+ // ── Bus event wiring ───────────────────────────────────────
498
+
499
+ private wireEvents(): void {
500
+ this.bus.onPipe("input:intercept", (payload) => this.handleIntercept(payload));
501
+ this.bus.onPipe("shell:redraw-prompt", (payload) => {
502
+ if (this._visible || this._passthrough) {
503
+ return { ...payload, handled: true };
504
+ }
505
+ // Suppress only freshPrompt's \n — an in-place redraw must not
506
+ // consume the slot, or unrelated mode-exit redraws go missing.
507
+ if (this.suppressNextRedraw && payload.kind === "fresh") {
508
+ this.suppressNextRedraw = false;
509
+ return { ...payload, handled: true };
510
+ }
511
+ return payload;
512
+ });
513
+ }
514
+
515
+ /** Check whether data matches any encoding of the trigger key. */
516
+ private isTrigger(data: string): boolean {
517
+ return this.triggerSeqs.includes(data);
518
+ }
519
+
520
+ // ── Lazy terminal buffer setup ──────────────────────────────
521
+
522
+ private ensureBuffer(): TerminalBuffer | null {
523
+ if (this.bufferInitialized) return this.buffer;
524
+ this.bufferInitialized = true;
525
+
526
+ if (!this.config.dimBackground) return null;
527
+
528
+ if (this.externalBuffer) {
529
+ this.buffer = this.externalBuffer;
530
+ } else {
531
+ this.buffer = TerminalBuffer.createWired(this.bus);
532
+ }
533
+
534
+ return this.buffer;
535
+ }
536
+
537
+ // ── Public lifecycle ────────────────────────────────────────
538
+
539
+ /** Whether the panel has an active conversation (may be hidden). */
540
+ get active(): boolean {
541
+ return this.phase !== "idle";
542
+ }
543
+
544
+ /** Whether the agent is currently processing a query. */
545
+ get processing(): boolean {
546
+ return this.phase === "active";
547
+ }
548
+
549
+ /** Whether the panel is currently visible on screen. */
550
+ get visible(): boolean {
551
+ return this._visible;
552
+ }
553
+
554
+ get terminalBuffer(): TerminalBuffer | null {
555
+ return this.buffer;
556
+ }
557
+
558
+ /** Open a fresh panel with a new conversation. */
559
+ open(): void {
560
+ if (this.phase !== "idle") return;
561
+ this.ensureBuffer();
562
+
563
+ this.phase = "input";
564
+ this.editor.clear();
565
+ this.clearAutocomplete();
566
+ this.contentLines = [];
567
+ this.currentPartialLine = "";
568
+ this.scrollOffset = 0;
569
+ this.userScrolled = false;
570
+ this.title = "";
571
+ this.footer = "";
572
+ this.prevFrame = [];
573
+
574
+ this.enterScreen();
575
+ }
576
+
577
+ /** Hide the panel without destroying conversation state. */
578
+ hide(): void {
579
+ if (!this._visible) return;
580
+ if (this.renderTimer) { clearTimeout(this.renderTimer); this.renderTimer = null; }
581
+ this._visible = false;
582
+ this.prevFrame = [];
583
+
584
+ if (this.phase === "active" && this.buffer) {
585
+ // Agent still working — enter passthrough mode.
586
+ // Keep alt screen + stdout held. Render TerminalBuffer directly
587
+ // so the background program's screen stays correct without
588
+ // handing rendering control back to ncurses.
589
+ this._passthrough = true;
590
+ this.startPassthrough();
591
+ } else {
592
+ // Agent idle or done — full teardown, hand back control.
593
+ this.teardownScreen();
594
+ }
595
+
596
+ this.handlers.call(`${this.prefix}:hide`);
597
+ }
598
+
599
+ /** Show the panel again after hide(), preserving conversation. */
600
+ show(): void {
601
+ if (this._visible || this.phase === "idle") return;
602
+
603
+ if (this._passthrough) {
604
+ // Resume from passthrough — alt screen + stdout hold already active.
605
+ this.stopPassthrough();
606
+ this._passthrough = false;
607
+ this._visible = true;
608
+ this.prevFrame = [];
609
+ this.render();
610
+ } else {
611
+ // Cold show — need full screen setup.
612
+ this.prevFrame = [];
613
+ this.enterScreen();
614
+ }
615
+ this.handlers.call(`${this.prefix}:show`);
616
+ }
617
+
618
+ /** End the conversation: screen down and all buffered state cleared. */
619
+ reset(): void {
620
+ if (this.phase === "idle") return;
621
+ if (this.autoDismissTimer) { clearTimeout(this.autoDismissTimer); this.autoDismissTimer = null; }
622
+
623
+ this.teardownToHidden();
624
+
625
+ this.phase = "idle";
626
+ this.editor.clear();
627
+ this.clearAutocomplete();
628
+ this.contentLines = [];
629
+ this.currentPartialLine = "";
630
+ this.scrollOffset = 0;
631
+ this.title = "";
632
+ this.footer = "";
633
+
634
+ this.handlers.call(`${this.prefix}:reset`);
635
+ }
636
+
637
+ /** Screen-only teardown; conversation state is left untouched. */
638
+ private teardownToHidden(): void {
639
+ if (this._passthrough) {
640
+ this.stopPassthrough();
641
+ this._passthrough = false;
642
+ this.teardownScreen();
643
+ } else if (this._visible) {
644
+ this._visible = false;
645
+ if (this.renderTimer) { clearTimeout(this.renderTimer); this.renderTimer = null; }
646
+ this.prevFrame = [];
647
+ this.teardownScreen();
648
+ }
649
+ }
650
+
651
+ /** Common screen enter logic shared by open() and show(). */
652
+ private enterScreen(): void {
653
+ this._visible = true;
654
+ this.bus.emit("shell:stdout-hold", {});
655
+
656
+ this.usedAltScreen = !(this.buffer?.altScreen);
657
+ if (this.usedAltScreen) {
658
+ this.surface.write("\x1b[?1049h");
659
+ }
660
+
661
+ this.resizeUnsub = this.surface.onResize(() => { this.prevFrame = []; this.render(); });
662
+
663
+ this.render();
664
+ }
665
+
666
+ // ── Public content API ──────────────────────────────────────
667
+
668
+ appendText(text: string): void {
669
+ for (const ch of text) {
670
+ if (ch === "\n") {
671
+ this.contentLines.push(this.currentPartialLine);
672
+ this.currentPartialLine = "";
673
+ } else {
674
+ this.currentPartialLine += ch;
675
+ }
676
+ }
677
+ this.scheduleRender();
678
+ }
679
+
680
+ appendLine(line: string): void {
681
+ if (this.currentPartialLine) {
682
+ this.contentLines.push(this.currentPartialLine);
683
+ this.currentPartialLine = "";
684
+ }
685
+ this.contentLines.push(line);
686
+ this.scheduleRender();
687
+ }
688
+
689
+ updateLastLine(fn: (line: string) => string): void {
690
+ if (this.contentLines.length > 0) {
691
+ this.contentLines[this.contentLines.length - 1] = fn(this.contentLines[this.contentLines.length - 1]!);
692
+ }
693
+ this.scheduleRender();
694
+ }
695
+
696
+ popLastLine(): void {
697
+ if (this.currentPartialLine) {
698
+ this.currentPartialLine = "";
699
+ } else if (this.contentLines.length > 0) {
700
+ this.contentLines.pop();
701
+ }
702
+ this.scheduleRender();
703
+ }
704
+
705
+ clearContent(): void {
706
+ this.contentLines = [];
707
+ this.currentPartialLine = "";
708
+ this.scrollOffset = 0;
709
+ this.scheduleRender();
710
+ }
711
+
712
+ setTitle(title: string): void {
713
+ this.title = title;
714
+ this.scheduleRender();
715
+ }
716
+
717
+ setFooter(footer: string): void {
718
+ this.footer = footer;
719
+ this.scheduleRender();
720
+ }
721
+
722
+ setActive(): void {
723
+ this.phase = "active";
724
+ }
725
+
726
+ setDone(): void {
727
+ if (this.config.autoDismissMs > 0) {
728
+ this.phase = "done";
729
+ this.autoDismissTimer = setTimeout(() => {
730
+ if (this.phase === "done") this.reset();
731
+ }, this.config.autoDismissMs);
732
+ } else {
733
+ this.phase = "input";
734
+ this.editor.clear();
735
+ this.clearAutocomplete();
736
+ }
737
+
738
+ if (this._passthrough) {
739
+ // Agent finished while hidden — release the screen but keep state
740
+ // so the next summon resumes the transcript.
741
+ this.teardownToHidden();
742
+ this.handlers.call(`${this.prefix}:hide`);
743
+ } else {
744
+ this.render();
745
+ }
746
+ }
747
+
748
+ scrollUp(lines = 3): void {
749
+ this.scrollOffset = Math.max(0, this.scrollOffset - lines);
750
+ this.userScrolled = true;
751
+ this.render();
752
+ }
753
+
754
+ scrollDown(lines = 3): void {
755
+ this.scrollOffset += lines;
756
+ this.userScrolled = true;
757
+ this.render();
758
+ }
759
+
760
+ getInput(): string {
761
+ return this.editor.text;
762
+ }
763
+
764
+ requestRender(): void {
765
+ this.scheduleRender();
766
+ }
767
+
768
+ // ── Autocomplete helpers ────────────────────────────────────
769
+
770
+ private updateAutocomplete(): void {
771
+ if (this.phase !== "input") {
772
+ this.clearAutocomplete();
773
+ return;
774
+ }
775
+ const buf = this.editor.text;
776
+ let command: string | null = null;
777
+ let commandArgs: string | null = null;
778
+ if (buf.startsWith("/")) {
779
+ const spaceIdx = buf.indexOf(" ");
780
+ if (spaceIdx !== -1) {
781
+ command = buf.slice(0, spaceIdx);
782
+ commandArgs = buf.slice(spaceIdx + 1);
783
+ }
784
+ }
785
+ const { items } = this.bus.emitPipe("autocomplete:request", {
786
+ buffer: buf,
787
+ command,
788
+ commandArgs,
789
+ items: [],
790
+ });
791
+ if (items.length > 0) {
792
+ this.autocompleteItems = items;
793
+ this.autocompleteActive = true;
794
+ if (this.autocompleteIndex >= items.length) this.autocompleteIndex = 0;
795
+ } else {
796
+ this.clearAutocomplete();
797
+ }
798
+ }
799
+
800
+ private applyAutocomplete(): boolean {
801
+ if (!this.autocompleteActive || this.autocompleteItems.length === 0) return false;
802
+ const sel = this.autocompleteItems[this.autocompleteIndex];
803
+ if (!sel) return false;
804
+
805
+ // For @file completion only the partial after the last @ is replaced.
806
+ const text = this.editor.text;
807
+ const atPos = text.lastIndexOf("@");
808
+ const isFileAc = atPos >= 0
809
+ && (atPos === 0 || text[atPos - 1] === " ")
810
+ && !text.slice(atPos + 1).includes(" ");
811
+ if (isFileAc) {
812
+ this.editor.setText(text.slice(0, atPos) + "@" + sel.name);
813
+ } else {
814
+ this.editor.setText(sel.name);
815
+ }
816
+ this.clearAutocomplete();
817
+ return true;
818
+ }
819
+
820
+ private clearAutocomplete(): void {
821
+ this.autocompleteActive = false;
822
+ this.autocompleteItems = [];
823
+ this.autocompleteIndex = 0;
824
+ }
825
+
826
+ // ── Input handling ──────────────────────────────────────────
827
+
828
+ private handleIntercept(payload: { data: string; consumed: boolean }): { data: string; consumed: boolean } {
829
+ const consumed = { ...payload, consumed: true };
830
+ const { data } = payload;
831
+
832
+ // Toggle visibility when trigger is pressed and panel is hidden but active
833
+ if (this.isTrigger(data) && this.phase !== "idle" && !this._visible) {
834
+ this.show();
835
+ return consumed;
836
+ }
837
+
838
+ // When not visible, only intercept the trigger key
839
+ if (!this._visible && this.phase !== "idle") {
840
+ return payload;
841
+ }
842
+
843
+ switch (this.phase) {
844
+ case "done":
845
+ this.reset();
846
+ return consumed;
847
+
848
+ case "input":
849
+ this.handleInputKey(data);
850
+ return consumed;
851
+
852
+ case "active":
853
+ if (data === "\x03") {
854
+ this.bus.emit("agent:cancel-request", {});
855
+ } else if (data === "\x1b" || this.isTrigger(data)) {
856
+ this.hide();
857
+ } else if (this.handleScroll(data)) {
858
+ // scroll handled
859
+ } else {
860
+ this.handlers.call(`${this.prefix}:input`, data);
861
+ }
862
+ return consumed;
863
+
864
+ default: // idle
865
+ if (this.isTrigger(data)) {
866
+ this.open();
867
+ return consumed;
868
+ }
869
+ return payload;
870
+ }
871
+ }
872
+
873
+ /**
874
+ * Handle scroll input. Returns true if consumed.
875
+ * Pass `includeArrows=false` in input phase so arrows reach the editor.
876
+ */
877
+ private handleScroll(data: string, includeArrows = true): boolean {
878
+ if (includeArrows) {
879
+ if (data === "\x1b[A" || data === "\x1bOA") { this.scrollUp(1); return true; }
880
+ if (data === "\x1b[B" || data === "\x1bOB") { this.scrollDown(1); return true; }
881
+ }
882
+ if (data === "\x1b[5~") { this.scrollUp(this.computeGeometry().contentH - 1); return true; }
883
+ if (data === "\x1b[6~") { this.scrollDown(this.computeGeometry().contentH - 1); return true; }
884
+ if (data.length >= 6 && data.startsWith("\x1b[M")) {
885
+ const button = data.charCodeAt(3);
886
+ if (button === 96) { this.scrollUp(3); return true; }
887
+ if (button === 97) { this.scrollDown(3); return true; }
888
+ }
889
+ const sgr = data.match(/^\x1b\[<(64|65);\d+;\d+M$/);
890
+ if (sgr) {
891
+ if (sgr[1] === "64") { this.scrollUp(3); return true; }
892
+ if (sgr[1] === "65") { this.scrollDown(3); return true; }
893
+ }
894
+ return false;
895
+ }
896
+
897
+ private handleInputKey(data: string): void {
898
+ if (this.isTrigger(data)) { this.hide(); return; }
899
+
900
+ for (let i = 0; i < data.length; i++) {
901
+ const ch = data[i]!;
902
+ if ((ch === "\x1b" && data[i + 1] == null) || ch.charCodeAt(0) === 0x03) {
903
+ // First Esc/Ctrl+C closes the dropdown; second hides the panel.
904
+ if (this.autocompleteActive) {
905
+ this.clearAutocomplete();
906
+ this.render();
907
+ return;
908
+ }
909
+ this.hide();
910
+ return;
911
+ }
912
+ }
913
+
914
+ if (this.handleScroll(data, false)) return;
915
+
916
+ const actions = this.editor.feed(data);
917
+ for (const action of actions) {
918
+ switch (action.action) {
919
+ case "submit": {
920
+ // Apply selection on Enter so it both picks and submits.
921
+ this.applyAutocomplete();
922
+ const query = this.editor.text.trim();
923
+ if (!query) { this.hide(); return; }
924
+ this.editor.pushHistory(query);
925
+ this.editor.clear();
926
+ this.clearAutocomplete();
927
+ // Phase change is the submit handler's call — sync slash commands
928
+ // (e.g. /model, /help) keep the user in input mode.
929
+ this.handlers.call(`${this.prefix}:submit`, query);
930
+ return;
931
+ }
932
+ case "cancel":
933
+ if (this.autocompleteActive) {
934
+ this.clearAutocomplete();
935
+ this.render();
936
+ return;
937
+ }
938
+ this.hide();
939
+ return;
940
+ case "tab":
941
+ // Re-query after applying a command name so arg completions show.
942
+ if (this.applyAutocomplete()) this.updateAutocomplete();
943
+ this.render();
944
+ break;
945
+ case "shift+tab":
946
+ this.render();
947
+ break;
948
+ case "arrow-up": {
949
+ if (this.autocompleteActive) {
950
+ this.autocompleteIndex = this.autocompleteIndex === 0
951
+ ? this.autocompleteItems.length - 1
952
+ : this.autocompleteIndex - 1;
953
+ this.render();
954
+ } else {
955
+ const hist = this.editor.historyBack();
956
+ if (hist) this.render();
957
+ }
958
+ break;
959
+ }
960
+ case "arrow-down": {
961
+ if (this.autocompleteActive) {
962
+ this.autocompleteIndex = this.autocompleteIndex === this.autocompleteItems.length - 1
963
+ ? 0
964
+ : this.autocompleteIndex + 1;
965
+ this.render();
966
+ } else {
967
+ const hist = this.editor.historyForward();
968
+ if (hist) this.render();
969
+ }
970
+ break;
971
+ }
972
+ case "changed":
973
+ case "delete-empty":
974
+ this.updateAutocomplete();
975
+ this.render();
976
+ break;
977
+ }
978
+ }
979
+ }
980
+
981
+ // ── Geometry ───────────────────────────────────────────────
982
+
983
+ /** Compute box geometry from config + current viewport. */
984
+ computeGeometry(): BoxGeometry {
985
+ const cols = this.surface.columns;
986
+ const rows = this.surface.rows;
987
+ const boxW = Math.min(this.resolveSize(this.config.width, cols - 4), this.config.maxWidth);
988
+ const boxH = Math.min(
989
+ this.resolveSize(this.config.height, rows - 4),
990
+ Math.max(this.config.minHeight + 2, rows - 4),
991
+ );
992
+ const boxTop = Math.floor((rows - boxH) / 2);
993
+ const boxLeft = Math.floor((cols - boxW) / 2);
994
+ return { cols, rows, boxW, boxH, boxTop, boxLeft, contentW: boxW - 4, contentH: boxH - 2 };
995
+ }
996
+
997
+ // ── Frame building ────────────────────────────────────────
998
+
999
+ private buildFrame(): FrameResult {
1000
+ const geo = this.computeGeometry();
1001
+
1002
+ // Call render-content handler
1003
+ const renderCtx: RenderContext = {
1004
+ width: geo.contentW,
1005
+ height: geo.contentH,
1006
+ phase: this.phase,
1007
+ inputBuffer: this.editor.displayText,
1008
+ inputCursor: this.editor.displayCursor,
1009
+ scrollOffset: this.scrollOffset,
1010
+ contentLines: this.contentLines,
1011
+ partialLine: this.currentPartialLine,
1012
+ };
1013
+ const content: RenderResult = this.handlers.call(`${this.prefix}:render-content`, renderCtx);
1014
+
1015
+ // Get background
1016
+ const bgLines = this.buffer?.getScreenLines(geo.rows) ?? null;
1017
+
1018
+ // Build frame context and delegate to render-frame handler
1019
+ const frameCtx: FrameContext = {
1020
+ geo,
1021
+ content,
1022
+ bgLines,
1023
+ phase: this.phase,
1024
+ title: this.title,
1025
+ footer: this.footer,
1026
+ border: this.border,
1027
+ };
1028
+
1029
+ return this.handlers.call(`${this.prefix}:render-frame`, frameCtx);
1030
+ }
1031
+
1032
+ // ── Rendering ─────────────────────────────────────────────
1033
+
1034
+ private scheduleRender(): void {
1035
+ if (this.renderTimer) return;
1036
+ this.renderTimer = setTimeout(() => {
1037
+ this.renderTimer = null;
1038
+ this.render();
1039
+ }, 32);
1040
+ }
1041
+
1042
+ private render(): void {
1043
+ if (this.phase === "idle" || !this._visible) return;
1044
+
1045
+ const { rows: frame, cursorSeq } = this.buildFrame();
1046
+
1047
+ // Differential write — only send rows that changed
1048
+ const out: string[] = [SYNC_START];
1049
+ let dirty = false;
1050
+
1051
+ for (let i = 0; i < frame.length; i++) {
1052
+ if (frame[i] !== this.prevFrame[i]) {
1053
+ out.push(`\x1b[${i + 1};1H`);
1054
+ out.push(frame[i]!);
1055
+ dirty = true;
1056
+ }
1057
+ }
1058
+ for (let i = frame.length; i < this.prevFrame.length; i++) {
1059
+ out.push(`\x1b[${i + 1};1H\x1b[2K`);
1060
+ dirty = true;
1061
+ }
1062
+
1063
+ if (cursorSeq) out.push(cursorSeq);
1064
+ out.push(SYNC_END);
1065
+
1066
+ if (this.prevFrame.length === 0 || dirty) {
1067
+ this.surface.write(out.join(""));
1068
+ }
1069
+
1070
+ this.prevFrame = frame;
1071
+ }
1072
+
1073
+ // ── Screen helpers ────────────────────────────────────────
1074
+
1075
+ private teardownScreen(): void {
1076
+ this.resizeUnsub?.();
1077
+ this.resizeUnsub = null;
1078
+ this.suppressNextRedraw = true;
1079
+
1080
+ this.buffer?.flush();
1081
+
1082
+ const programInAlt = !!this.buffer?.altScreen;
1083
+
1084
+ if (!this.usedAltScreen && programInAlt) {
1085
+ // Program still in its own alt-screen — SIGWINCH so it redraws
1086
+ // and re-asserts its modes; replaying from the mirror would
1087
+ // freeze modes serialize() doesn't track (modifyOtherKeys, kitty
1088
+ // kbd) and leave ctrl-c arriving as \x1b[27;5;99~.
1089
+ this.bus.emit("shell:stdout-release", {});
1090
+ const cols = this.surface.columns;
1091
+ const rows = this.surface.rows;
1092
+ this.bus.emit("shell:pty-resize", { cols, rows: rows - 1 });
1093
+ setTimeout(() => {
1094
+ this.bus.emit("shell:pty-resize", { cols, rows });
1095
+ }, 50);
1096
+ return;
1097
+ }
1098
+
1099
+ this.surface.write("\x1b[?1049l");
1100
+
1101
+ if (!this.usedAltScreen) {
1102
+ // Alt-screen TUI exited mid-overlay; its reset bytes were eaten
1103
+ // by stdout-hold. Re-emit the modes commonly set by full-screen
1104
+ // programs (vim, neovim, emacs -nw, less, htop, tmux, ssh→TUI):
1105
+ // modifyOtherKeys, kitty kbd, bracketed paste, focus reporting,
1106
+ // mouse, DECCKM cursor-key mode, application keypad, cursor
1107
+ // blink. Without this, arrow keys / keypad digits / cursor state
1108
+ // misbehave at the post-overlay shell prompt.
1109
+ this.surface.write(
1110
+ "\x1b[>4;0m\x1b[<u\x1b[?2004l\x1b[?1004l" +
1111
+ "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l" +
1112
+ "\x1b[?1l\x1b>\x1b[?12l",
1113
+ );
1114
+ }
1115
+
1116
+ this.bus.emit("shell:stdout-release", {});
1117
+
1118
+ const serialized = this.buffer?.serialize();
1119
+ if (serialized) {
1120
+ this.surface.write(`${SYNC_START}\x1b[2J\x1b[H${serialized}${SYNC_END}`);
1121
+ }
1122
+ }
1123
+
1124
+ // ── Passthrough rendering ─────────────────────────────────
1125
+
1126
+ /** Start rendering TerminalBuffer directly (no overlay box). */
1127
+ private startPassthrough(): void {
1128
+ this.prevSerialized = "";
1129
+ this.renderPassthrough();
1130
+ this.passthroughTimer = setInterval(() => this.renderPassthrough(), 50);
1131
+ }
1132
+
1133
+ private stopPassthrough(): void {
1134
+ if (this.passthroughTimer) {
1135
+ clearInterval(this.passthroughTimer);
1136
+ this.passthroughTimer = null;
1137
+ }
1138
+ this.prevSerialized = "";
1139
+ }
1140
+
1141
+ /** Render the TerminalBuffer's screen content directly (no overlay). */
1142
+ private renderPassthrough(): void {
1143
+ if (!this.buffer) return;
1144
+ this.buffer.flush();
1145
+ const serialized = this.buffer.serialize();
1146
+ if (serialized && serialized !== this.prevSerialized) {
1147
+ this.prevSerialized = serialized;
1148
+ this.surface.write(`${SYNC_START}\x1b[2J\x1b[H${serialized}${SYNC_END}`);
1149
+ }
1150
+ }
1151
+
1152
+ private resolveSize(spec: number | string, available: number): number {
1153
+ if (typeof spec === "number") return Math.min(spec, available);
1154
+ if (typeof spec === "string" && spec.endsWith("%")) {
1155
+ const pct = parseInt(spec, 10) / 100;
1156
+ return Math.floor(available * pct);
1157
+ }
1158
+ return available;
1159
+ }
1160
+ }