agent-sh 0.12.1 → 0.12.3

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 (39) hide show
  1. package/README.md +10 -4
  2. package/dist/agent/agent-loop.js +34 -16
  3. package/dist/agent/conversation-state.d.ts +4 -0
  4. package/dist/agent/conversation-state.js +44 -0
  5. package/dist/agent/skills.js +2 -2
  6. package/dist/agent/system-prompt.js +2 -3
  7. package/dist/agent/tools/bash.js +10 -3
  8. package/dist/agent/types.d.ts +3 -1
  9. package/dist/core.d.ts +2 -0
  10. package/dist/core.js +5 -3
  11. package/dist/event-bus.d.ts +22 -0
  12. package/dist/event-bus.js +51 -3
  13. package/dist/extension-loader.js +1 -0
  14. package/dist/extensions/agent-backend.js +4 -1
  15. package/dist/extensions/openrouter.js +32 -0
  16. package/dist/index.js +1 -0
  17. package/dist/init.js +1 -2
  18. package/dist/settings.d.ts +8 -0
  19. package/dist/settings.js +7 -3
  20. package/dist/shell/input-handler.d.ts +8 -18
  21. package/dist/shell/input-handler.js +57 -227
  22. package/dist/shell/output-parser.d.ts +2 -1
  23. package/dist/shell/output-parser.js +33 -18
  24. package/dist/shell/shell.d.ts +1 -0
  25. package/dist/shell/shell.js +9 -7
  26. package/dist/shell/tui-input-view.d.ts +37 -0
  27. package/dist/shell/tui-input-view.js +140 -0
  28. package/dist/types.d.ts +6 -0
  29. package/dist/utils/compositor.d.ts +7 -1
  30. package/dist/utils/compositor.js +13 -1
  31. package/dist/utils/floating-panel.d.ts +6 -2
  32. package/dist/utils/floating-panel.js +17 -17
  33. package/dist/utils/ref-counter.d.ts +9 -0
  34. package/dist/utils/ref-counter.js +9 -0
  35. package/package.json +3 -1
  36. package/dist/utils/frame-renderer.d.ts +0 -26
  37. package/dist/utils/frame-renderer.js +0 -76
  38. package/dist/utils/output-writer.d.ts +0 -36
  39. package/dist/utils/output-writer.js +0 -45
@@ -1,4 +1,9 @@
1
1
  import { stripAnsi } from "../utils/ansi.js";
2
+ // Self-emitted form: \e]<num>;id=<own>;<body>\a — only this is honored.
3
+ // Anything else (mismatched tag, untagged) is ignored as opaque foreground output.
4
+ const PROMPT_RE = /\x1b\]9999;(?:id=([a-f0-9]+);)?PROMPT\x07/;
5
+ const PREEXEC_RE = /\x1b\]9997;(?:id=([a-f0-9]+);)?([^\x07]*)\x07/;
6
+ const READY_RE = /\x1b\]9998;(?:id=([a-f0-9]+);)?READY\x07/;
2
7
  /**
3
8
  * Parses PTY output to detect command boundaries, track cwd,
4
9
  * and emit shell events. Owns the command lifecycle state.
@@ -6,13 +11,16 @@ import { stripAnsi } from "../utils/ansi.js";
6
11
  export class OutputParser {
7
12
  bus;
8
13
  cwd;
14
+ ownTag;
9
15
  currentOutputCapture = "";
10
16
  lastCommand = "";
11
17
  foregroundBusy = false;
12
18
  promptReady = false;
13
- constructor(bus, initialCwd) {
19
+ constructor(bus, initialCwd, ownTag) {
14
20
  this.bus = bus;
15
21
  this.cwd = initialCwd;
22
+ // Strip the "id=" prefix; we compare the value alone.
23
+ this.ownTag = ownTag.startsWith("id=") ? ownTag.slice(3) : ownTag;
16
24
  }
17
25
  /** Process a chunk of PTY output data. */
18
26
  processData(data) {
@@ -49,24 +57,22 @@ export class OutputParser {
49
57
  * completion. Returns data with the OSC stripped out.
50
58
  */
51
59
  handlePreexec(data) {
52
- const marker = "\x1b]9997;";
53
- const idx = data.indexOf(marker);
54
- if (idx === -1)
60
+ const match = PREEXEC_RE.exec(data);
61
+ if (!match)
55
62
  return data;
56
- const endIdx = data.indexOf("\x07", idx + marker.length);
57
- if (endIdx === -1)
58
- return data; // incomplete OSC, wait for next chunk
59
- const command = data.slice(idx + marker.length, endIdx);
60
- // Authoritative command from the shell — override any lineBuffer guess
63
+ if (match[1] !== this.ownTag) {
64
+ // Nested instance or untagged foreign emission — strip and ignore.
65
+ return data.slice(0, match.index) + data.slice(match.index + match[0].length);
66
+ }
67
+ const command = match[2];
61
68
  this.lastCommand = command;
62
- this.currentOutputCapture = ""; // discard echoed text accumulated before preexec
69
+ this.currentOutputCapture = ""; // discard echo accumulated before preexec
63
70
  if (!this.foregroundBusy) {
64
71
  this.foregroundBusy = true;
65
72
  this.bus.emit("shell:foreground-busy", { busy: true });
66
73
  }
67
74
  this.bus.emit("shell:command-start", { command, cwd: this.cwd });
68
- // Return only data after the OSC — everything before was the echo
69
- return data.slice(endIdx + 1);
75
+ return data.slice(match.index + match[0].length);
70
76
  }
71
77
  parseOSC7(data) {
72
78
  const match = data.match(/\x1b\]7;file:\/\/[^/]*(\/[^\x07\x1b]*)/);
@@ -83,9 +89,15 @@ export class OutputParser {
83
89
  * Each time a prompt appears, we finalize the previous command's output.
84
90
  */
85
91
  parsePromptMarker(data) {
86
- const marker = "\x1b]9999;PROMPT\x07";
87
- const markerIdx = data.indexOf(marker);
88
- if (markerIdx !== -1) {
92
+ const match = PROMPT_RE.exec(data);
93
+ if (match) {
94
+ if (match[1] !== this.ownTag) {
95
+ // Nested instance or untagged foreign emission — treat as opaque
96
+ // foreground output, do not finalize our own command.
97
+ this.currentOutputCapture += data;
98
+ return;
99
+ }
100
+ const markerIdx = match.index;
89
101
  // Capture any output that arrived in the same chunk before the marker
90
102
  if (markerIdx > 0) {
91
103
  this.currentOutputCapture += data.slice(0, markerIdx);
@@ -125,9 +137,12 @@ export class OutputParser {
125
137
  * and the shell is ready for input.
126
138
  */
127
139
  parsePromptEnd(data) {
128
- if (data.includes("\x1b]9998;READY\x07")) {
129
- this.promptReady = true;
130
- }
140
+ const match = READY_RE.exec(data);
141
+ if (!match)
142
+ return;
143
+ if (match[1] !== this.ownTag)
144
+ return;
145
+ this.promptReady = true;
131
146
  }
132
147
  removeEchoedCommand(output, command) {
133
148
  const lines = output.split("\n");
@@ -28,6 +28,7 @@ export declare class Shell implements InputContext {
28
28
  rows: number;
29
29
  shell: string;
30
30
  cwd: string;
31
+ instanceId: string;
31
32
  });
32
33
  isForegroundBusy(): boolean;
33
34
  getCwd(): string;
@@ -5,7 +5,7 @@ import * as pty from "node-pty";
5
5
  import { InputHandler } from "./input-handler.js";
6
6
  import { OutputParser } from "./output-parser.js";
7
7
  import { getSettings } from "../settings.js";
8
- import { RefCounter } from "../utils/output-writer.js";
8
+ import { RefCounter } from "../utils/ref-counter.js";
9
9
  export class Shell {
10
10
  ptyProcess;
11
11
  bus;
@@ -43,8 +43,10 @@ export class Shell {
43
43
  }
44
44
  const shellBin = (isZsh || isBash) ? opts.shell : "/bin/bash";
45
45
  let shellArgs;
46
+ // Per-instance tag so nested agent-sh hooks don't cross-trigger.
47
+ const instanceTag = `id=${opts.instanceId}`;
46
48
  const osc7Cmd = 'printf "\\e]7;file://%s%s\\a" "$(hostname)" "$PWD"';
47
- const promptMarker = 'printf "\\e]9999;PROMPT\\a"';
49
+ const promptMarker = `printf "\\e]9999;${instanceTag};PROMPT\\a"`;
48
50
  const titleCmd = 'printf "\\e]0;⚡ agent-sh: %s\\a" "${PWD/#$HOME/~}"';
49
51
  this.isZsh = isZsh;
50
52
  const settings = getSettings();
@@ -69,11 +71,11 @@ export class Shell {
69
71
  "# Preexec hook: emit actual command text so agent-sh can track",
70
72
  "# history-recalled and tab-completed commands accurately",
71
73
  "__agent_sh_preexec() {",
72
- ' printf "\\e]9997;%s\\a" "$1"',
74
+ ` printf "\\e]9997;${instanceTag};%s\\a" "$1"`,
73
75
  "}",
74
76
  "preexec_functions+=(__agent_sh_preexec)",
75
77
  ];
76
- zshrcLines.push("", "# End-of-prompt marker via zle-line-init (fires after prompt is rendered)", "# Chain onto existing widget (p10k uses zle-line-init) rather than clobbering", 'if (( ${+widgets[zle-line-init]} )); then', " zle -A zle-line-init __agent_sh_orig_line_init", " __agent_sh_line_init() {", " zle __agent_sh_orig_line_init", ' printf "\\e]9998;READY\\a"', " }", "else", " __agent_sh_line_init() {", ' printf "\\e]9998;READY\\a"', " }", "fi", "zle -N zle-line-init __agent_sh_line_init", "", "# Hidden widget to trigger prompt redraw from Node.js side", "# Bound to an unused escape sequence that no real key produces", "__agent_sh_redraw() {", " zle reset-prompt", "}", "zle -N __agent_sh_redraw", "bindkey '\\e[9999~' __agent_sh_redraw");
78
+ zshrcLines.push("", "# End-of-prompt marker via zle-line-init (fires after prompt is rendered)", "# Chain onto existing widget (p10k uses zle-line-init) rather than clobbering", 'if (( ${+widgets[zle-line-init]} )); then', " zle -A zle-line-init __agent_sh_orig_line_init", " __agent_sh_line_init() {", " zle __agent_sh_orig_line_init", ` printf "\\e]9998;${instanceTag};READY\\a"`, " }", "else", " __agent_sh_line_init() {", ` printf "\\e]9998;${instanceTag};READY\\a"`, " }", "fi", "zle -N zle-line-init __agent_sh_line_init", "", "# Hidden widget to trigger prompt redraw from Node.js side", "# Bound to an unused escape sequence that no real key produces", "__agent_sh_redraw() {", " zle reset-prompt", "}", "zle -N __agent_sh_redraw", "bindkey '\\e[9999~' __agent_sh_redraw");
77
79
  fs.writeFileSync(path.join(this.tmpDir, ".zshrc"), zshrcLines.join("\n") + "\n");
78
80
  env.ZDOTDIR = this.tmpDir;
79
81
  shellArgs = ["--no-globalrcs"];
@@ -106,12 +108,12 @@ export class Shell {
106
108
  " __agent_sh_preexec_ran=1",
107
109
  " local this_cmd",
108
110
  ` this_cmd=$(HISTTIMEFORMAT='' builtin history 1 | command sed 's/^ *[0-9]* *//')`,
109
- ` printf '\\e]9997;%s\\a' "$this_cmd"`,
111
+ ` printf '\\e]9997;${instanceTag};%s\\a' "$this_cmd"`,
110
112
  "}",
111
113
  "trap '__agent_sh_emit_preexec' DEBUG",
112
114
  "",
113
115
  "# End-of-prompt marker: append to PS1 (\\[...\\] marks it zero-width)",
114
- 'case "$PS1" in *9998*) ;; *) PS1="${PS1}\\[\\e]9998;READY\\a\\]";; esac',
116
+ `case "$PS1" in *9998*) ;; *) PS1="\${PS1}\\[\\e]9998;${instanceTag};READY\\a\\]";; esac`,
115
117
  "",
116
118
  "# Mirrors the zsh \\e[9999~ reset-prompt widget — used by agent-sh",
117
119
  "# to repaint the prompt in place. All keymaps so `set -o vi` works.",
@@ -155,7 +157,7 @@ export class Shell {
155
157
  }
156
158
  this.bus = opts.bus;
157
159
  this.handlers = opts.handlers;
158
- this.outputParser = new OutputParser(opts.bus, opts.cwd);
160
+ this.outputParser = new OutputParser(opts.bus, opts.cwd, instanceTag);
159
161
  // Ensure temp dir cleanup on abnormal exit (SIGKILL won't fire this,
160
162
  // but it covers uncaught exceptions and normal process.exit paths)
161
163
  if (this.tmpDir) {
@@ -0,0 +1,37 @@
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
+ import type { RenderSurface } from "../utils/compositor.js";
7
+ export interface PromptVM {
8
+ showBuffer: boolean;
9
+ displayText: string;
10
+ displayCursor: number;
11
+ indicator: string;
12
+ promptIcon: string;
13
+ agentInfo: {
14
+ info: string;
15
+ };
16
+ }
17
+ export interface AutocompleteVM {
18
+ items: {
19
+ name: string;
20
+ description: string;
21
+ }[];
22
+ selected: number;
23
+ }
24
+ export declare class TuiInputView {
25
+ private cursorRowsBelow;
26
+ private cursorTermCol;
27
+ private autocompleteLines;
28
+ private readonly surface;
29
+ constructor(surface?: RenderSurface);
30
+ resetCursor(): void;
31
+ enableModeKeys(): void;
32
+ disableModeKeys(): void;
33
+ clearPromptArea(): void;
34
+ drawPrompt(vm: PromptVM): void;
35
+ drawAutocomplete(vm: AutocompleteVM): void;
36
+ clearAutocomplete(): void;
37
+ }
@@ -0,0 +1,140 @@
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
+ import { visibleLen } from "../utils/ansi.js";
7
+ import { palette as p } from "../utils/palette.js";
8
+ import { StdoutSurface } from "../utils/compositor.js";
9
+ export class TuiInputView {
10
+ cursorRowsBelow = 0;
11
+ cursorTermCol = 1;
12
+ autocompleteLines = 0;
13
+ surface;
14
+ constructor(surface) {
15
+ this.surface = surface ?? new StdoutSurface();
16
+ }
17
+ resetCursor() {
18
+ this.cursorRowsBelow = 0;
19
+ this.cursorTermCol = 1;
20
+ }
21
+ enableModeKeys() {
22
+ // Kitty progressive enhancement + bracket paste (Shift+Enter → \x1b[13;2u).
23
+ this.surface.write("\x1b[>1u\x1b[?2004h");
24
+ }
25
+ disableModeKeys() {
26
+ this.surface.write("\x1b[<u\x1b[?2004l");
27
+ }
28
+ clearPromptArea() {
29
+ if (this.cursorRowsBelow > 0) {
30
+ this.surface.write(`\x1b[${this.cursorRowsBelow}A`);
31
+ }
32
+ this.surface.write("\r\x1b[J");
33
+ this.cursorRowsBelow = 0;
34
+ }
35
+ drawPrompt(vm) {
36
+ const termW = this.surface.columns;
37
+ if (this.cursorRowsBelow > 0) {
38
+ this.surface.write(`\x1b[${this.cursorRowsBelow}A`);
39
+ }
40
+ this.surface.write("\r\x1b[J");
41
+ const infoPrefix = vm.agentInfo.info
42
+ ? `${vm.agentInfo.info} ${p.success}${vm.indicator}${p.reset} `
43
+ : `${p.success}${vm.indicator}${p.reset} `;
44
+ const promptPrefix = infoPrefix + p.warning + p.bold + vm.promptIcon + " " + p.reset;
45
+ const promptVisLen = visibleLen(infoPrefix) + visibleLen(vm.promptIcon) + 1;
46
+ const display = vm.showBuffer ? vm.displayText : "";
47
+ const dCursor = vm.showBuffer ? vm.displayCursor : 0;
48
+ if (!vm.showBuffer) {
49
+ this.surface.write(promptPrefix);
50
+ const N = promptVisLen;
51
+ this.cursorRowsBelow = N > 0 ? Math.ceil(N / termW) - 1 : 0;
52
+ this.cursorTermCol = N === 0 ? 1 : (N % termW === 0 ? termW : (N % termW) + 1);
53
+ }
54
+ else if (!display.includes("\n")) {
55
+ // DECSC/DECRC bracket the after-cursor text so the cursor lands mid-line.
56
+ const before = display.slice(0, dCursor);
57
+ const after = display.slice(dCursor);
58
+ this.surface.write(promptPrefix + p.accent + before + p.reset +
59
+ "\x1b7" +
60
+ p.accent + after + p.reset +
61
+ "\x1b8");
62
+ const cursorVisCol = promptVisLen + visibleLen(before);
63
+ this.cursorRowsBelow = cursorVisCol > 0 ? Math.ceil(cursorVisCol / termW) - 1 : 0;
64
+ this.cursorTermCol = cursorVisCol === 0 ? 1 : (cursorVisCol % termW === 0 ? termW : (cursorVisCol % termW) + 1);
65
+ }
66
+ else {
67
+ const lines = display.split("\n");
68
+ const indent = " ".repeat(promptVisLen);
69
+ let charsRemaining = dCursor;
70
+ let cursorLine = 0;
71
+ for (let li = 0; li < lines.length; li++) {
72
+ if (charsRemaining <= lines[li].length) {
73
+ cursorLine = li;
74
+ break;
75
+ }
76
+ charsRemaining -= lines[li].length + 1;
77
+ cursorLine = li + 1;
78
+ }
79
+ let output = "";
80
+ let cursorRowFromTop = 0;
81
+ let rowsSoFar = 0;
82
+ for (let li = 0; li < lines.length; li++) {
83
+ const prefix = li === 0 ? promptPrefix : indent;
84
+ const lineText = lines[li];
85
+ const lineVisLen = promptVisLen + visibleLen(lineText);
86
+ const lineTermRows = lineVisLen > 0 ? Math.ceil(lineVisLen / termW) : 1;
87
+ if (li === cursorLine) {
88
+ const before = lineText.slice(0, charsRemaining);
89
+ const after = lineText.slice(charsRemaining);
90
+ output += prefix + p.accent + before + p.reset;
91
+ output += "\x1b7";
92
+ output += p.accent + after + p.reset;
93
+ const beforeVisCol = promptVisLen + visibleLen(before);
94
+ cursorRowFromTop = rowsSoFar + (beforeVisCol > 0 ? Math.ceil(beforeVisCol / termW) - 1 : 0);
95
+ this.cursorTermCol = beforeVisCol === 0 ? 1 : (beforeVisCol % termW === 0 ? termW : (beforeVisCol % termW) + 1);
96
+ }
97
+ else {
98
+ output += prefix + p.accent + lineText + p.reset;
99
+ }
100
+ if (li < lines.length - 1)
101
+ output += "\n";
102
+ rowsSoFar += lineTermRows;
103
+ }
104
+ this.surface.write(output + "\x1b8");
105
+ this.cursorRowsBelow = cursorRowFromTop;
106
+ }
107
+ }
108
+ drawAutocomplete(vm) {
109
+ if (vm.items.length === 0)
110
+ return;
111
+ const lines = [];
112
+ for (let i = 0; i < vm.items.length; i++) {
113
+ const item = vm.items[i];
114
+ const selected = i === vm.selected;
115
+ if (selected) {
116
+ lines.push(` \x1b[7m ${p.accent}${item.name.padEnd(12)}${p.reset}\x1b[7m ${item.description} ${p.reset}`);
117
+ }
118
+ else {
119
+ lines.push(` ${p.muted}${item.name.padEnd(12)} ${item.description}${p.reset}`);
120
+ }
121
+ }
122
+ this.surface.write("\n" + lines.join("\n"));
123
+ this.autocompleteLines = lines.length;
124
+ if (this.autocompleteLines > 0) {
125
+ this.surface.write(`\x1b[${this.autocompleteLines}A`);
126
+ }
127
+ // Absolute column set — preceding \n may have scrolled, invalidating DECSC.
128
+ this.surface.write(`\x1b[${this.cursorTermCol}G`);
129
+ }
130
+ clearAutocomplete() {
131
+ if (this.autocompleteLines <= 0)
132
+ return;
133
+ // CSI B (cursor down, bounded) so we don't scroll on the last row.
134
+ for (let i = 0; i < this.autocompleteLines; i++) {
135
+ this.surface.write("\x1b[B\x1b[2K");
136
+ }
137
+ this.surface.write(`\x1b[${this.autocompleteLines}A\x1b[${this.cursorTermCol}G`);
138
+ this.autocompleteLines = 0;
139
+ }
140
+ }
package/dist/types.d.ts CHANGED
@@ -48,6 +48,9 @@ export interface AgentMode {
48
48
  reasoning?: boolean;
49
49
  /** Provider supports the reasoning_effort parameter. */
50
50
  supportsReasoningEffort?: boolean;
51
+ /** Echo reasoning_content back on assistant turns. Required by DeepSeek;
52
+ * default off (leaky shims may forward it to the model as OOD input). */
53
+ echoReasoning?: boolean;
51
54
  }
52
55
  /**
53
56
  * Backend-agnostic LLM interface exposed via `ctx.llm`. Backends fulfill it
@@ -146,6 +149,9 @@ export interface ExtensionContext {
146
149
  * Extensions use `compositor.redirect()` to capture output (e.g. overlay panels).
147
150
  */
148
151
  compositor: Compositor;
152
+ /** Teardown callback fired on /reload. For resources the scoped context
153
+ * can't track: process listeners, timers, watchers, sockets. */
154
+ onDispose: (fn: () => void) => void;
149
155
  /**
150
156
  * Create a remote session that routes agent output to a surface and
151
157
  * optionally accepts queries. Handles all compositor routing, shell
@@ -36,6 +36,10 @@ export interface RenderSurface {
36
36
  writeLine(line: string): void;
37
37
  /** Available width in columns. */
38
38
  readonly columns: number;
39
+ /** Available height in rows. */
40
+ readonly rows: number;
41
+ /** Subscribe to size changes. Returns unsubscribe. */
42
+ onResize(cb: (cols: number, rows: number) => void): () => void;
39
43
  }
40
44
  export interface Compositor {
41
45
  /** Get the currently active surface for a stream. */
@@ -48,11 +52,13 @@ export interface Compositor {
48
52
  }
49
53
  /** Silent sink — drops all output. Used when no surface is registered. */
50
54
  export declare const nullSurface: RenderSurface;
51
- /** Surface backed by process.stdout. */
55
+ /** Surface backed by process.stdout — the only sanctioned bridge to it. */
52
56
  export declare class StdoutSurface implements RenderSurface {
53
57
  write(text: string): void;
54
58
  writeLine(line: string): void;
55
59
  get columns(): number;
60
+ get rows(): number;
61
+ onResize(cb: (cols: number, rows: number) => void): () => void;
56
62
  }
57
63
  export declare class DefaultCompositor implements Compositor {
58
64
  private defaults;
@@ -29,8 +29,10 @@ export const nullSurface = {
29
29
  write() { },
30
30
  writeLine() { },
31
31
  get columns() { return 80; },
32
+ get rows() { return 24; },
33
+ onResize() { return () => { }; },
32
34
  };
33
- /** Surface backed by process.stdout. */
35
+ /** Surface backed by process.stdout — the only sanctioned bridge to it. */
34
36
  export class StdoutSurface {
35
37
  write(text) {
36
38
  if (process.stdout.writable) {
@@ -46,6 +48,14 @@ export class StdoutSurface {
46
48
  get columns() {
47
49
  return process.stdout.columns || 80;
48
50
  }
51
+ get rows() {
52
+ return process.stdout.rows || 24;
53
+ }
54
+ onResize(cb) {
55
+ const handler = () => cb(this.columns, this.rows);
56
+ process.stdout.on("resize", handler);
57
+ return () => { process.stdout.off("resize", handler); };
58
+ }
49
59
  }
50
60
  export class DefaultCompositor {
51
61
  defaults = new Map();
@@ -111,6 +121,8 @@ export class DefaultCompositor {
111
121
  target.writeLine(line);
112
122
  },
113
123
  get columns() { return target.columns; },
124
+ get rows() { return target.rows; },
125
+ onResize: (cb) => target.onResize(cb),
114
126
  };
115
127
  }
116
128
  }
@@ -2,6 +2,7 @@ import { TerminalBuffer } from "./terminal-buffer.js";
2
2
  import { HandlerRegistry } from "./handler-registry.js";
3
3
  import type { EventBus } from "../event-bus.js";
4
4
  import type { BorderStyle } from "./box-frame.js";
5
+ import { type RenderSurface } from "./compositor.js";
5
6
  export interface FloatingPanelConfig {
6
7
  /** Key sequence that toggles the panel (e.g. "\x1c" for Ctrl+\). */
7
8
  trigger: string;
@@ -36,6 +37,8 @@ export interface FloatingPanelConfig {
36
37
  * `{prefix}:submit`, etc. Use different prefixes for multiple panels.
37
38
  */
38
39
  handlerPrefix?: string;
40
+ /** Render sink + viewport. Defaults to a fresh StdoutSurface. */
41
+ surface?: RenderSurface;
39
42
  }
40
43
  /**
41
44
  * Context passed to the render-content handler.
@@ -129,6 +132,7 @@ export type Phase = "idle" | "input" | "active" | "done";
129
132
  export declare class FloatingPanel {
130
133
  private readonly config;
131
134
  private readonly bus;
135
+ private readonly surface;
132
136
  private readonly border;
133
137
  private readonly externalBuffer;
134
138
  private readonly prefix;
@@ -164,7 +168,7 @@ export declare class FloatingPanel {
164
168
  private title;
165
169
  private footer;
166
170
  private renderTimer;
167
- private resizeHandler;
171
+ private resizeUnsub;
168
172
  private prevFrame;
169
173
  private suppressNextRedraw;
170
174
  private autoDismissTimer;
@@ -213,7 +217,7 @@ export declare class FloatingPanel {
213
217
  /** Handle scroll input. Returns true if consumed. */
214
218
  private handleScroll;
215
219
  private handleInputKey;
216
- /** Compute box geometry from config + current terminal size. */
220
+ /** Compute box geometry from config + current viewport. */
217
221
  computeGeometry(): BoxGeometry;
218
222
  private buildFrame;
219
223
  private scheduleRender;
@@ -35,6 +35,7 @@ import { wrapLine } from "./markdown.js";
35
35
  import { LineEditor } from "./line-editor.js";
36
36
  import { TerminalBuffer } from "./terminal-buffer.js";
37
37
  import { HandlerRegistry } from "./handler-registry.js";
38
+ import { StdoutSurface } from "./compositor.js";
38
39
  // ── ANSI constants ──────────────────────────────────────────────
39
40
  const DIM = "\x1b[2m";
40
41
  const RESET = "\x1b[0m";
@@ -74,6 +75,7 @@ export class FloatingPanel {
74
75
  // ── Configuration ───────────────────────────────────────────
75
76
  config;
76
77
  bus;
78
+ surface;
77
79
  border;
78
80
  externalBuffer;
79
81
  prefix;
@@ -112,7 +114,7 @@ export class FloatingPanel {
112
114
  title = "";
113
115
  footer = "";
114
116
  renderTimer = null;
115
- resizeHandler = null;
117
+ resizeUnsub = null;
116
118
  prevFrame = [];
117
119
  suppressNextRedraw = false;
118
120
  autoDismissTimer = null;
@@ -124,6 +126,7 @@ export class FloatingPanel {
124
126
  prevSerialized = "";
125
127
  constructor(bus, config, handlers) {
126
128
  this.bus = bus;
129
+ this.surface = config.surface ?? new StdoutSurface();
127
130
  this.externalBuffer = config.terminalBuffer;
128
131
  this.prefix = config.handlerPrefix ?? "panel";
129
132
  this.handlers = handlers ?? new HandlerRegistry();
@@ -436,10 +439,9 @@ export class FloatingPanel {
436
439
  this.bus.emit("shell:stdout-hold", {});
437
440
  this.usedAltScreen = !(this.buffer?.altScreen);
438
441
  if (this.usedAltScreen) {
439
- process.stdout.write("\x1b[?1049h");
442
+ this.surface.write("\x1b[?1049h");
440
443
  }
441
- this.resizeHandler = () => { this.prevFrame = []; this.render(); };
442
- process.stdout.on("resize", this.resizeHandler);
444
+ this.resizeUnsub = this.surface.onResize(() => { this.prevFrame = []; this.render(); });
443
445
  this.render();
444
446
  }
445
447
  // ── Public content API ──────────────────────────────────────
@@ -674,10 +676,10 @@ export class FloatingPanel {
674
676
  }
675
677
  }
676
678
  // ── Geometry ───────────────────────────────────────────────
677
- /** Compute box geometry from config + current terminal size. */
679
+ /** Compute box geometry from config + current viewport. */
678
680
  computeGeometry() {
679
- const cols = process.stdout.columns || 80;
680
- const rows = process.stdout.rows || 24;
681
+ const cols = this.surface.columns;
682
+ const rows = this.surface.rows;
681
683
  const boxW = Math.min(this.resolveSize(this.config.width, cols - 4), this.config.maxWidth);
682
684
  const boxH = Math.min(this.resolveSize(this.config.height, rows - 4), Math.max(this.config.minHeight + 2, rows - 4));
683
685
  const boxTop = Math.floor((rows - boxH) / 2);
@@ -744,24 +746,22 @@ export class FloatingPanel {
744
746
  out.push(cursorSeq);
745
747
  out.push(SYNC_END);
746
748
  if (this.prevFrame.length === 0 || dirty) {
747
- process.stdout.write(out.join(""));
749
+ this.surface.write(out.join(""));
748
750
  }
749
751
  this.prevFrame = frame;
750
752
  }
751
753
  // ── Screen helpers ────────────────────────────────────────
752
754
  /** Full screen teardown: exit alt screen, release stdout, force redraw. */
753
755
  teardownScreen() {
754
- if (this.resizeHandler) {
755
- process.stdout.off("resize", this.resizeHandler);
756
- this.resizeHandler = null;
757
- }
756
+ this.resizeUnsub?.();
757
+ this.resizeUnsub = null;
758
758
  this.suppressNextRedraw = true;
759
759
  // Re-check alt screen state: the program we overlaid may have exited
760
760
  // (e.g. agent quit vim via terminal_keys) while the panel was active.
761
761
  const stillInAltScreen = !this.usedAltScreen && !!this.buffer?.altScreen;
762
762
  const programExited = !this.usedAltScreen && !stillInAltScreen;
763
763
  if (this.usedAltScreen) {
764
- process.stdout.write("\x1b[?1049l");
764
+ this.surface.write("\x1b[?1049l");
765
765
  }
766
766
  // Replay PTY output that arrived while the overlay was active.
767
767
  // Without this, commands run by the agent (e.g. user_shell ls)
@@ -769,7 +769,7 @@ export class FloatingPanel {
769
769
  // from before the overlay opened, losing any shell output produced
770
770
  // during the session.
771
771
  if (this.ptyBuffer) {
772
- process.stdout.write(this.ptyBuffer);
772
+ this.surface.write(this.ptyBuffer);
773
773
  }
774
774
  this.ptyBuffer = "";
775
775
  this.bus.emit("shell:stdout-release", {});
@@ -778,8 +778,8 @@ export class FloatingPanel {
778
778
  // or the overlaid program exited (e.g. agent quit vim) and we
779
779
  // discarded its stale buffer — SIGWINCH makes the shell redraw
780
780
  // its prompt cleanly.
781
- const cols = process.stdout.columns || 80;
782
- const rows = process.stdout.rows || 24;
781
+ const cols = this.surface.columns;
782
+ const rows = this.surface.rows;
783
783
  this.bus.emit("shell:pty-resize", { cols, rows: rows - 1 });
784
784
  setTimeout(() => {
785
785
  this.bus.emit("shell:pty-resize", { cols, rows });
@@ -808,7 +808,7 @@ export class FloatingPanel {
808
808
  const serialized = this.buffer.serialize();
809
809
  if (serialized && serialized !== this.prevSerialized) {
810
810
  this.prevSerialized = serialized;
811
- process.stdout.write(`${SYNC_START}\x1b[H${serialized}${SYNC_END}`);
811
+ this.surface.write(`${SYNC_START}\x1b[H${serialized}${SYNC_END}`);
812
812
  }
813
813
  }
814
814
  resolveSize(spec, available) {
@@ -0,0 +1,9 @@
1
+ /** Simple ref-counted counter. Increment/decrement never goes below zero. */
2
+ export declare class RefCounter {
3
+ private count;
4
+ increment(): void;
5
+ decrement(): void;
6
+ reset(): void;
7
+ get active(): boolean;
8
+ get value(): number;
9
+ }
@@ -0,0 +1,9 @@
1
+ /** Simple ref-counted counter. Increment/decrement never goes below zero. */
2
+ export class RefCounter {
3
+ count = 0;
4
+ increment() { this.count++; }
5
+ decrement() { this.count = Math.max(0, this.count - 1); }
6
+ reset() { this.count = 0; }
7
+ get active() { return this.count > 0; }
8
+ get value() { return this.count; }
9
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.12.1",
3
+ "version": "0.12.3",
4
4
  "description": "A shell-first terminal where AI is one keystroke away",
5
5
  "type": "module",
6
6
  "main": "dist/core.js",
@@ -127,6 +127,8 @@
127
127
  "node": ">=18"
128
128
  },
129
129
  "dependencies": {
130
+ "@xterm/addon-serialize": "^0.13.0",
131
+ "@xterm/headless": "^5.5.0",
130
132
  "cli-highlight": "^2.1.11",
131
133
  "diff": "^9.0.0",
132
134
  "marked": "^17.0.6",
@@ -1,26 +0,0 @@
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
- }