agent-sh 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
+ import * as path from "node:path";
3
4
  import { Shell } from "./shell.js";
4
5
  import { createCore } from "./core.js";
5
6
  import { palette as p } from "./utils/palette.js";
@@ -10,16 +11,23 @@ import shellRecall from "./extensions/shell-recall.js";
10
11
  import shellExec from "./extensions/shell-exec.js";
11
12
  import { loadExtensions } from "./extension-loader.js";
12
13
  /**
13
- * Capture the user's full shell environment asynchronously.
14
+ * Capture the user's full shell environment.
14
15
  * This picks up env vars exported in .zshrc/.bashrc that the
15
- * Node.js process doesn't have.
16
+ * Node.js process doesn't have (e.g. when launched from an IDE).
16
17
  *
17
- * Uses -l (login shell) instead of -i to avoid TTY blocking issues.
18
+ * Uses -l (login shell) to get .zprofile/.bash_profile vars, then
19
+ * explicitly sources the interactive rc file (.zshrc/.bashrc) which
20
+ * -l alone doesn't load (that requires -i, which blocks on TTY).
18
21
  */
19
22
  async function captureShellEnvAsync(shell) {
20
23
  return new Promise((resolve) => {
21
24
  try {
22
- const child = spawn(shell, ["-l", "-c", "env -0"], {
25
+ const shellName = path.basename(shell);
26
+ const isZsh = shellName.includes("zsh");
27
+ const sourceRc = isZsh
28
+ ? 'source ~/.zshrc 2>/dev/null;'
29
+ : '[ -f ~/.bashrc ] && source ~/.bashrc 2>/dev/null;';
30
+ const child = spawn(shell, ["-l", "-c", `${sourceRc} env -0`], {
23
31
  stdio: ["ignore", "pipe", "ignore"],
24
32
  timeout: 5000,
25
33
  });
@@ -154,7 +162,7 @@ function formatAgentInfo(agentInfo, model, thoughtLevel) {
154
162
  const label = thoughtLevel.replace(/^Thinking:\s*/i, "");
155
163
  infoStr += ` ${p.dim}[${label}]${p.reset}`;
156
164
  }
157
- return `${infoStr} ${p.success}●${p.reset}`;
165
+ return infoStr;
158
166
  }
159
167
  async function main() {
160
168
  // Set up signal handlers before any terminal operations.
@@ -163,29 +171,26 @@ async function main() {
163
171
  // Also ignore SIGTTIN which can occur when reading from terminal while backgrounded.
164
172
  process.on("SIGTTIN", () => { });
165
173
  const config = parseArgs(process.argv.slice(2));
166
- // Start with current process environment (fast, non-blocking)
167
- // We'll enrich it with shell env asynchronously in the background
174
+ // Capture user's full shell environment (from .zshrc/.bashrc etc.)
175
+ // This must complete before spawning the agent so it sees all env vars.
168
176
  const baseEnv = {};
169
177
  for (const [k, v] of Object.entries(process.env)) {
170
178
  if (v !== undefined)
171
179
  baseEnv[k] = v;
172
180
  }
173
181
  config.shellEnv = baseEnv;
174
- // Asynchronously capture full shell environment without blocking startup
175
182
  const shellPath = config.shell || process.env.SHELL || "/bin/bash";
176
- captureShellEnvAsync(shellPath).then((shellEnv) => {
183
+ try {
184
+ const shellEnv = await captureShellEnvAsync(shellPath);
177
185
  if (Object.keys(shellEnv).length > 0) {
178
- const merged = mergeShellEnv(config.shellEnv, shellEnv);
179
- config.shellEnv = merged;
186
+ config.shellEnv = mergeShellEnv(config.shellEnv, shellEnv);
180
187
  if (process.env.DEBUG) {
181
- console.error('[agent-sh] Shell environment enriched asynchronously');
188
+ console.error('[agent-sh] Shell environment captured');
182
189
  }
183
190
  }
184
- }).catch(() => {
191
+ }
192
+ catch {
185
193
  // Ignore errors, we already have process.env as fallback
186
- });
187
- if (process.env.DEBUG) {
188
- console.error('[agent-sh] Using current process environment (async enrichment pending)');
189
194
  }
190
195
  // ── Core (frontend-agnostic) ──────────────────────────────────
191
196
  const core = createCore(config);
@@ -232,6 +237,29 @@ async function main() {
232
237
  if (process.env.DEBUG) {
233
238
  console.error('[agent-sh] Shell created');
234
239
  }
240
+ // ── Input modes ──────────────────────────────────────────────
241
+ bus.emit("input-mode:register", {
242
+ id: "query",
243
+ trigger: "?",
244
+ label: "query",
245
+ promptIcon: "❯",
246
+ indicator: "❓",
247
+ onSubmit(query, b) {
248
+ b.emit("agent:submit", { query, modeLabel: "Query", modeInstruction: "[mode: query]" });
249
+ },
250
+ returnToSelf: true,
251
+ });
252
+ bus.emit("input-mode:register", {
253
+ id: "execute",
254
+ trigger: ">",
255
+ label: "execute",
256
+ promptIcon: "⟩",
257
+ indicator: "●",
258
+ onSubmit(query, b) {
259
+ b.emit("agent:submit", { query, modeLabel: "Execute", modeInstruction: "[mode: execute]" });
260
+ },
261
+ returnToSelf: false,
262
+ });
235
263
  // ── Extensions ────────────────────────────────────────────────
236
264
  if (process.env.DEBUG) {
237
265
  console.error('[agent-sh] Setting up extensions...');
@@ -16,7 +16,10 @@ export interface InputContext {
16
16
  export declare class InputHandler {
17
17
  private ctx;
18
18
  private lineBuffer;
19
- private agentInputMode;
19
+ private activeMode;
20
+ private pendingReturnMode;
21
+ private modes;
22
+ private modesById;
20
23
  private editor;
21
24
  private autocompleteActive;
22
25
  private autocompleteIndex;
@@ -37,22 +40,28 @@ export declare class InputHandler {
37
40
  model?: string;
38
41
  };
39
42
  });
43
+ private registerMode;
40
44
  private loadHistory;
41
45
  private saveHistory;
42
- /** Write the agent prompt line with cursor at the correct position. */
43
- private writeAgentPromptLine;
46
+ /** Write the mode prompt line with cursor at the correct position. */
47
+ private writeModePromptLine;
44
48
  handleInput(data: string): void;
45
- private enterAgentInputMode;
46
- private exitAgentInputMode;
49
+ private enterMode;
50
+ private exitMode;
47
51
  /** Move to the start of the prompt area and clear everything below. */
48
52
  private clearPromptArea;
49
53
  printPrompt(): void;
50
- private renderAgentInput;
54
+ /**
55
+ * Called when agent processing completes. Returns true if the input
56
+ * handler re-entered a mode (so caller should skip shell prompt).
57
+ */
58
+ handleProcessingDone(): boolean;
59
+ private renderModeInput;
51
60
  private updateAutocomplete;
52
61
  private renderAutocomplete;
53
62
  private applyAutocomplete;
54
63
  private dismissAutocomplete;
55
64
  private clearAutocompleteLines;
56
- private handleAgentInput;
57
- private processAgentActions;
65
+ private handleModeInput;
66
+ private processModeActions;
58
67
  }
@@ -8,7 +8,10 @@ const HISTORY_FILE = path.join(CONFIG_DIR, "history");
8
8
  export class InputHandler {
9
9
  ctx;
10
10
  lineBuffer = "";
11
- agentInputMode = false;
11
+ activeMode = null;
12
+ pendingReturnMode = null; // mode id to return to after processing
13
+ modes = new Map(); // keyed by trigger char
14
+ modesById = new Map(); // keyed by id
12
15
  editor = new LineEditor();
13
16
  autocompleteActive = false;
14
17
  autocompleteIndex = 0;
@@ -28,9 +31,23 @@ export class InputHandler {
28
31
  this.loadHistory();
29
32
  // Re-render prompt when config changes (e.g. thinking level cycled)
30
33
  this.bus.on("config:changed", () => {
31
- if (this.agentInputMode)
32
- this.writeAgentPromptLine();
34
+ if (this.activeMode)
35
+ this.writeModePromptLine();
33
36
  });
37
+ // Listen for mode registrations from extensions
38
+ this.bus.on("input-mode:register", (config) => {
39
+ this.registerMode(config);
40
+ });
41
+ }
42
+ registerMode(config) {
43
+ if (this.modes.has(config.trigger)) {
44
+ this.bus.emit("ui:error", {
45
+ message: `Input mode "${config.id}" cannot register trigger "${config.trigger}" — already taken by "${this.modes.get(config.trigger).id}"`,
46
+ });
47
+ return;
48
+ }
49
+ this.modes.set(config.trigger, config);
50
+ this.modesById.set(config.id, config);
34
51
  }
35
52
  loadHistory() {
36
53
  try {
@@ -52,8 +69,8 @@ export class InputHandler {
52
69
  // Non-critical — ignore write failures
53
70
  }
54
71
  }
55
- /** Write the agent prompt line with cursor at the correct position. */
56
- writeAgentPromptLine(showBuffer = true) {
72
+ /** Write the mode prompt line with cursor at the correct position. */
73
+ writeModePromptLine(showBuffer = true) {
57
74
  const termW = process.stdout.columns || 80;
58
75
  // Move cursor to the start of the prompt area (first line of wrapped content)
59
76
  if (this.promptWrappedLines > 0) {
@@ -62,9 +79,13 @@ export class InputHandler {
62
79
  // Clear from here to end of screen — removes current + all wrapped lines below
63
80
  process.stdout.write("\r\x1b[J");
64
81
  const agentInfo = this.onShowAgentInfo();
65
- const infoPrefix = agentInfo.info ? `${agentInfo.info} ` : "";
66
- const promptPrefix = infoPrefix + p.warning + p.bold + "❯ " + p.reset;
67
- const promptVisLen = visibleLen(infoPrefix) + 2; // "❯ "
82
+ const indicator = this.activeMode?.indicator ?? "";
83
+ const infoPrefix = agentInfo.info
84
+ ? `${agentInfo.info} ${p.success}${indicator}${p.reset} `
85
+ : `${p.success}${indicator}${p.reset} `;
86
+ const icon = this.activeMode?.promptIcon ?? "❯";
87
+ const promptPrefix = infoPrefix + p.warning + p.bold + icon + " " + p.reset;
88
+ const promptVisLen = visibleLen(infoPrefix) + visibleLen(icon) + 1; // icon + space
68
89
  if (!showBuffer || !this.editor.buffer.includes("\n")) {
69
90
  // Single-line: simple rendering
70
91
  const bufferText = showBuffer ? p.accent + this.editor.buffer + p.reset : "";
@@ -127,7 +148,7 @@ export class InputHandler {
127
148
  return;
128
149
  }
129
150
  // Intercept control chars for TUI (Ctrl+T, Ctrl+O) — don't pass to PTY
130
- if (data.length === 1 && data.charCodeAt(0) < 32 && !this.agentInputMode) {
151
+ if (data.length === 1 && data.charCodeAt(0) < 32 && !this.activeMode) {
131
152
  const code = data.charCodeAt(0);
132
153
  // Keys consumed by TUI extensions
133
154
  if (code === 0x14 || code === 0x0f) { // Ctrl+T, Ctrl+O
@@ -139,9 +160,9 @@ export class InputHandler {
139
160
  this.bus.emit("input:keypress", { key: data });
140
161
  }
141
162
  }
142
- // If in agent input mode (typing a query after ">")
143
- if (this.agentInputMode) {
144
- this.handleAgentInput(data);
163
+ // If in an input mode (typing a query)
164
+ if (this.activeMode) {
165
+ this.handleModeInput(data);
145
166
  return;
146
167
  }
147
168
  for (let i = 0; i < data.length; i++) {
@@ -171,10 +192,11 @@ export class InputHandler {
171
192
  this.ctx.writeToPty(ch);
172
193
  }
173
194
  else {
174
- // Check if ">" at start of empty line → enter agent input mode
195
+ // Check if trigger char at start of empty line → enter that mode
175
196
  // But not if a foreground process (ssh, vim, etc.) is running
176
- if (this.lineBuffer === "" && ch === ">" && !this.ctx.isForegroundBusy()) {
177
- this.enterAgentInputMode();
197
+ const mode = this.modes.get(ch);
198
+ if (this.lineBuffer === "" && mode && !this.ctx.isForegroundBusy()) {
199
+ this.enterMode(mode);
178
200
  return; // don't process remaining chars
179
201
  }
180
202
  this.lineBuffer += ch;
@@ -182,17 +204,17 @@ export class InputHandler {
182
204
  }
183
205
  }
184
206
  }
185
- enterAgentInputMode() {
186
- this.agentInputMode = true;
207
+ enterMode(mode) {
208
+ this.activeMode = mode;
187
209
  this.editor.clear();
188
210
  // Enable kitty keyboard protocol (progressive enhancement flag 1)
189
211
  // so Shift+Enter sends \x1b[13;2u instead of plain \r
190
212
  process.stdout.write("\x1b[>1u");
191
- this.writeAgentPromptLine(false);
213
+ this.writeModePromptLine(false);
192
214
  }
193
- exitAgentInputMode() {
215
+ exitMode() {
194
216
  this.dismissAutocomplete();
195
- this.agentInputMode = false;
217
+ this.activeMode = null;
196
218
  this.editor.clear();
197
219
  // Disable kitty keyboard protocol
198
220
  process.stdout.write("\x1b[<u");
@@ -210,9 +232,24 @@ export class InputHandler {
210
232
  printPrompt() {
211
233
  this.ctx.redrawPrompt();
212
234
  }
213
- renderAgentInput() {
235
+ /**
236
+ * Called when agent processing completes. Returns true if the input
237
+ * handler re-entered a mode (so caller should skip shell prompt).
238
+ */
239
+ handleProcessingDone() {
240
+ if (this.pendingReturnMode) {
241
+ const mode = this.modesById.get(this.pendingReturnMode);
242
+ this.pendingReturnMode = null;
243
+ if (mode) {
244
+ this.enterMode(mode);
245
+ return true;
246
+ }
247
+ }
248
+ return false;
249
+ }
250
+ renderModeInput() {
214
251
  this.clearAutocompleteLines();
215
- this.writeAgentPromptLine();
252
+ this.writeModePromptLine();
216
253
  this.updateAutocomplete();
217
254
  }
218
255
  updateAutocomplete() {
@@ -254,7 +291,8 @@ export class InputHandler {
254
291
  }
255
292
  const agentInfo = this.onShowAgentInfo();
256
293
  const infoLength = visibleLen(agentInfo.info);
257
- const col = infoLength + 2 + this.editor.cursor;
294
+ const icon = this.activeMode?.promptIcon ?? "❯";
295
+ const col = infoLength + visibleLen(icon) + 1 + this.editor.cursor;
258
296
  process.stdout.write(`\r\x1b[${col}C`);
259
297
  }
260
298
  applyAutocomplete() {
@@ -279,7 +317,7 @@ export class InputHandler {
279
317
  this.autocompleteActive = false;
280
318
  this.autocompleteItems = [];
281
319
  this.autocompleteIndex = 0;
282
- this.writeAgentPromptLine();
320
+ this.writeModePromptLine();
283
321
  if (isFileAc)
284
322
  this.updateAutocomplete();
285
323
  }
@@ -299,7 +337,7 @@ export class InputHandler {
299
337
  process.stdout.write("\x1b8"); // restore cursor
300
338
  this.autocompleteLines = 0;
301
339
  }
302
- handleAgentInput(data) {
340
+ handleModeInput(data) {
303
341
  // Clear any pending escape timer — new data arrived
304
342
  if (this.escapeTimer) {
305
343
  clearTimeout(this.escapeTimer);
@@ -313,18 +351,18 @@ export class InputHandler {
313
351
  this.escapeTimer = null;
314
352
  const flushed = this.editor.flushPendingEscape();
315
353
  if (flushed.length > 0)
316
- this.processAgentActions(flushed);
354
+ this.processModeActions(flushed);
317
355
  }, 50);
318
356
  }
319
- this.processAgentActions(actions);
357
+ this.processModeActions(actions);
320
358
  }
321
- processAgentActions(actions) {
359
+ processModeActions(actions) {
322
360
  for (const act of actions) {
323
361
  switch (act.action) {
324
362
  case "changed":
325
363
  this.historyIndex = -1;
326
364
  this.autocompleteIndex = 0;
327
- this.renderAgentInput();
365
+ this.renderModeInput();
328
366
  break;
329
367
  case "submit": {
330
368
  if (this.autocompleteActive) {
@@ -343,7 +381,8 @@ export class InputHandler {
343
381
  this.clearAutocompleteLines();
344
382
  this.clearPromptArea();
345
383
  process.stdout.write("\x1b[<u"); // disable kitty keyboard protocol
346
- this.agentInputMode = false;
384
+ const currentMode = this.activeMode;
385
+ this.activeMode = null;
347
386
  this.editor.clear();
348
387
  this.dismissAutocomplete();
349
388
  if (query && query.startsWith("/")) {
@@ -354,25 +393,26 @@ export class InputHandler {
354
393
  this.ctx.redrawPrompt();
355
394
  }
356
395
  else if (query) {
357
- this.bus.emit("agent:submit", { query });
396
+ this.pendingReturnMode = currentMode.returnToSelf ? currentMode.id : null;
397
+ currentMode.onSubmit(query, this.bus);
358
398
  }
359
399
  else {
360
- this.exitAgentInputMode();
400
+ this.exitMode();
361
401
  }
362
402
  return;
363
403
  }
364
404
  case "cancel":
365
405
  if (this.autocompleteActive) {
366
406
  this.dismissAutocomplete();
367
- this.writeAgentPromptLine();
407
+ this.writeModePromptLine();
368
408
  }
369
409
  else {
370
- this.exitAgentInputMode();
410
+ this.exitMode();
371
411
  }
372
412
  return;
373
413
  case "delete-empty":
374
414
  this.dismissAutocomplete();
375
- this.exitAgentInputMode();
415
+ this.exitMode();
376
416
  return;
377
417
  case "tab":
378
418
  if (this.autocompleteActive) {
@@ -389,7 +429,7 @@ export class InputHandler {
389
429
  ? this.autocompleteItems.length - 1
390
430
  : this.autocompleteIndex - 1;
391
431
  this.clearAutocompleteLines();
392
- this.writeAgentPromptLine();
432
+ this.writeModePromptLine();
393
433
  this.renderAutocomplete();
394
434
  }
395
435
  else if (this.history.length > 0) {
@@ -402,7 +442,7 @@ export class InputHandler {
402
442
  }
403
443
  this.editor.buffer = this.history[this.historyIndex];
404
444
  this.editor.cursor = this.editor.buffer.length;
405
- this.renderAgentInput();
445
+ this.renderModeInput();
406
446
  }
407
447
  break;
408
448
  case "arrow-down":
@@ -412,7 +452,7 @@ export class InputHandler {
412
452
  ? 0
413
453
  : this.autocompleteIndex + 1;
414
454
  this.clearAutocompleteLines();
415
- this.writeAgentPromptLine();
455
+ this.writeModePromptLine();
416
456
  this.renderAutocomplete();
417
457
  }
418
458
  else if (this.historyIndex !== -1) {
@@ -425,7 +465,7 @@ export class InputHandler {
425
465
  this.editor.buffer = this.savedBuffer;
426
466
  }
427
467
  this.editor.cursor = this.editor.buffer.length;
428
- this.renderAgentInput();
468
+ this.renderModeInput();
429
469
  }
430
470
  break;
431
471
  }
@@ -28,6 +28,17 @@ export interface Settings {
28
28
  declare const DEFAULTS: Required<Settings>;
29
29
  /** Load settings from disk (cached after first call). */
30
30
  export declare function getSettings(): Settings & typeof DEFAULTS;
31
+ /**
32
+ * Get settings for an extension, namespaced under its key in settings.json.
33
+ *
34
+ * Example settings.json:
35
+ * { "latex-images": { "dpi": 600, "fgColor": "ffffff" } }
36
+ *
37
+ * Usage in extension:
38
+ * const config = getExtensionSettings("latex-images", { dpi: 300, fgColor: "d4d4d4" });
39
+ * // config.dpi === 600 (overridden), config.fgColor === "ffffff" (overridden)
40
+ */
41
+ export declare function getExtensionSettings<T extends Record<string, unknown>>(namespace: string, defaults: T): T;
31
42
  /** Reset cached settings (for testing or after external edit). */
32
43
  export declare function reloadSettings(): void;
33
44
  export {};
package/dist/settings.js CHANGED
@@ -18,7 +18,7 @@ const DEFAULTS = {
18
18
  shellHeadLines: 5,
19
19
  shellTailLines: 5,
20
20
  recallExpandMaxLines: 100,
21
- maxCommandOutputLines: 5,
21
+ maxCommandOutputLines: 3,
22
22
  readOutputMaxLines: 0,
23
23
  diffMaxLines: 20,
24
24
  enableMcp: true,
@@ -37,6 +37,24 @@ export function getSettings() {
37
37
  }
38
38
  return { ...DEFAULTS, ...cached };
39
39
  }
40
+ /**
41
+ * Get settings for an extension, namespaced under its key in settings.json.
42
+ *
43
+ * Example settings.json:
44
+ * { "latex-images": { "dpi": 600, "fgColor": "ffffff" } }
45
+ *
46
+ * Usage in extension:
47
+ * const config = getExtensionSettings("latex-images", { dpi: 300, fgColor: "d4d4d4" });
48
+ * // config.dpi === 600 (overridden), config.fgColor === "ffffff" (overridden)
49
+ */
50
+ export function getExtensionSettings(namespace, defaults) {
51
+ const all = getSettings();
52
+ const ext = all[namespace];
53
+ if (ext && typeof ext === "object" && !Array.isArray(ext)) {
54
+ return { ...defaults, ...ext };
55
+ }
56
+ return defaults;
57
+ }
40
58
  /** Reset cached settings (for testing or after external edit). */
41
59
  export function reloadSettings() {
42
60
  cached = null;
package/dist/shell.js CHANGED
@@ -239,7 +239,9 @@ export class Shell {
239
239
  this.paused = false;
240
240
  this.agentActive = false;
241
241
  this.echoSkip = true;
242
- this.freshPrompt();
242
+ if (!this.inputHandler.handleProcessingDone()) {
243
+ this.freshPrompt();
244
+ }
243
245
  });
244
246
  // Permission prompts need stdout unpaused so the interactive UI renders,
245
247
  // then re-paused after the decision.
package/dist/types.d.ts CHANGED
@@ -2,6 +2,9 @@ import type { EventBus } from "./event-bus.js";
2
2
  import type { ContextManager } from "./context-manager.js";
3
3
  import type { AcpClient } from "./acp-client.js";
4
4
  import type { ColorPalette } from "./utils/palette.js";
5
+ import type { BlockTransformOptions, FencedBlockTransformOptions } from "./utils/stream-transform.js";
6
+ export type { ContentBlock } from "./event-bus.js";
7
+ export type { BlockTransformOptions, FencedBlockTransformOptions } from "./utils/stream-transform.js";
5
8
  export interface AgentShellConfig {
6
9
  agentCommand: string;
7
10
  agentArgs: string[];
@@ -24,6 +27,31 @@ export interface ExtensionContext {
24
27
  quit: () => void;
25
28
  /** Override color palette slots for theming. */
26
29
  setPalette: (overrides: Partial<ColorPalette>) => void;
30
+ /** Register a delimiter-based content transform (e.g. $$...$$ → image). */
31
+ createBlockTransform: (opts: BlockTransformOptions) => void;
32
+ /** Register a fenced block transform (e.g. ```lang...``` → code-block). */
33
+ createFencedBlockTransform: (opts: FencedBlockTransformOptions) => void;
34
+ /** Read extension-namespaced settings from ~/.agent-sh/settings.json. */
35
+ getExtensionSettings: <T extends Record<string, unknown>>(namespace: string, defaults: T) => T;
36
+ /** Register a named handler. */
37
+ define: (name: string, fn: (...args: any[]) => any) => void;
38
+ /** Wrap a named handler. Receives `next` (original) + args. */
39
+ advise: (name: string, wrapper: (next: (...args: any[]) => any, ...args: any[]) => any) => void;
40
+ /** Call a named handler. */
41
+ call: (name: string, ...args: any[]) => any;
42
+ }
43
+ /**
44
+ * Configuration for a registered input mode.
45
+ * Extensions emit "input-mode:register" with this shape to add new modes.
46
+ */
47
+ export interface InputModeConfig {
48
+ id: string;
49
+ trigger: string;
50
+ label: string;
51
+ promptIcon: string;
52
+ indicator: string;
53
+ onSubmit(query: string, bus: EventBus): void;
54
+ returnToSelf: boolean;
27
55
  }
28
56
  export interface TerminalSession {
29
57
  id: string;
@@ -22,7 +22,8 @@ const BORDERS = {
22
22
  * @returns Array of terminal-ready lines with borders
23
23
  */
24
24
  export function renderBoxFrame(content, opts) {
25
- const { width, borderColor = p.dim } = opts;
25
+ const { width: rawWidth, borderColor = p.dim } = opts;
26
+ const width = Math.max(6, rawWidth);
26
27
  const style = opts.style ?? "rounded";
27
28
  const b = BORDERS[style];
28
29
  const bc = borderColor;
@@ -320,7 +320,7 @@ function renderSplit(diff, opts) {
320
320
  const lang = useSyntax ? detectLanguage(opts.filePath) : undefined;
321
321
  const totalWidth = opts.width;
322
322
  // 3 chars for " │ " separator
323
- const colWidth = Math.floor((totalWidth - 3) / 2);
323
+ const colWidth = Math.max(1, Math.floor((totalWidth - 3) / 2));
324
324
  // Compute max line number width
325
325
  let maxNo = 0;
326
326
  for (const hunk of diff.hunks) {
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Differential frame renderer.
3
+ *
4
+ * Accepts a frame (string[]) and writes only the lines that changed
5
+ * compared to the previous frame. Designed for scrolling content
6
+ * (not full-screen ownership like pi-tui).
7
+ *
8
+ * Fast paths:
9
+ * 1. First render → write everything
10
+ * 2. Append-only → write only new lines
11
+ * 3. Last line changed → \r overwrite (for spinner / partial streaming)
12
+ * 4. General diff → cursor-up, rewrite changed region, cursor-down
13
+ */
14
+ import type { OutputWriter } from "./output-writer.js";
15
+ export declare class FrameRenderer {
16
+ private writer;
17
+ private prevLines;
18
+ constructor(writer: OutputWriter);
19
+ /**
20
+ * Render a new frame, writing only the diff to the output.
21
+ * Each line in `lines` should NOT include a trailing newline.
22
+ */
23
+ update(lines: string[]): void;
24
+ /** Reset state — next update will be treated as a first render. */
25
+ reset(): void;
26
+ }
@@ -0,0 +1,76 @@
1
+ export class FrameRenderer {
2
+ writer;
3
+ prevLines = [];
4
+ constructor(writer) {
5
+ this.writer = writer;
6
+ }
7
+ /**
8
+ * Render a new frame, writing only the diff to the output.
9
+ * Each line in `lines` should NOT include a trailing newline.
10
+ */
11
+ update(lines) {
12
+ const prev = this.prevLines;
13
+ if (prev.length === 0) {
14
+ // Fast path 1: first render
15
+ for (const line of lines) {
16
+ this.writer.write(line + "\n");
17
+ }
18
+ this.prevLines = lines.slice();
19
+ return;
20
+ }
21
+ // Find first and last changed indices
22
+ const minLen = Math.min(prev.length, lines.length);
23
+ let firstChanged = -1;
24
+ let lastChanged = -1;
25
+ for (let i = 0; i < minLen; i++) {
26
+ if (prev[i] !== lines[i]) {
27
+ if (firstChanged === -1)
28
+ firstChanged = i;
29
+ lastChanged = i;
30
+ }
31
+ }
32
+ // Check for appended or removed lines
33
+ const appended = lines.length > prev.length;
34
+ const truncated = lines.length < prev.length;
35
+ if (firstChanged === -1 && !appended && !truncated) {
36
+ // No changes at all
37
+ this.prevLines = lines.slice();
38
+ return;
39
+ }
40
+ if (firstChanged === -1 && appended) {
41
+ // Fast path 2: only new lines appended, existing unchanged
42
+ for (let i = prev.length; i < lines.length; i++) {
43
+ this.writer.write(lines[i] + "\n");
44
+ }
45
+ this.prevLines = lines.slice();
46
+ return;
47
+ }
48
+ // General diff: move cursor up to first changed line, rewrite
49
+ const linesFromBottom = prev.length - (firstChanged === -1 ? prev.length : firstChanged);
50
+ if (linesFromBottom > 0) {
51
+ this.writer.write(`\x1b[${linesFromBottom}A`); // cursor up
52
+ }
53
+ this.writer.write("\r"); // start of line
54
+ // Rewrite from firstChanged to end of new frame
55
+ const start = firstChanged === -1 ? prev.length : firstChanged;
56
+ for (let i = start; i < lines.length; i++) {
57
+ this.writer.write(`\x1b[2K${lines[i]}\n`); // clear line + write + newline
58
+ }
59
+ // If new frame is shorter, clear remaining old lines
60
+ if (truncated) {
61
+ for (let i = lines.length; i < prev.length; i++) {
62
+ this.writer.write("\x1b[2K\n");
63
+ }
64
+ // Move cursor back up to end of new content
65
+ const extra = prev.length - lines.length;
66
+ if (extra > 0) {
67
+ this.writer.write(`\x1b[${extra}A`);
68
+ }
69
+ }
70
+ this.prevLines = lines.slice();
71
+ }
72
+ /** Reset state — next update will be treated as a first render. */
73
+ reset() {
74
+ this.prevLines = [];
75
+ }
76
+ }