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.
- package/README.md +38 -42
- package/dist/agent/agent-loop.d.ts +9 -17
- package/dist/agent/agent-loop.js +104 -136
- package/dist/agent/events.d.ts +8 -11
- package/dist/agent/host-types.d.ts +17 -11
- package/dist/agent/index.d.ts +1 -1
- package/dist/agent/index.js +38 -22
- package/dist/agent/providers/deepseek.js +9 -1
- package/dist/agent/session-store.js +1 -1
- package/dist/agent/system-prompt.d.ts +7 -3
- package/dist/agent/system-prompt.js +11 -14
- package/dist/agent/tool-protocol.js +0 -7
- package/dist/cli/args.js +2 -1
- package/dist/cli/install.d.ts +1 -0
- package/dist/cli/install.js +29 -1
- package/dist/cli/subcommands.js +1 -0
- package/dist/core/event-bus.js +0 -2
- package/dist/core/extension-loader.js +3 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +3 -2
- package/dist/extensions/slash-commands/index.js +16 -11
- package/dist/shell/index.js +9 -0
- package/dist/shell/shell-context.d.ts +2 -2
- package/dist/shell/shell-context.js +26 -11
- package/dist/shell/tui-renderer.js +0 -1
- package/dist/utils/diff-renderer.js +2 -9
- package/dist/utils/handler-registry.d.ts +1 -6
- package/dist/utils/handler-registry.js +1 -6
- package/dist/utils/line-editor.js +0 -2
- package/dist/utils/palette.js +4 -4
- package/dist/utils/terminal-buffer.d.ts +2 -0
- package/dist/utils/terminal-buffer.js +4 -0
- package/docs/README.md +14 -0
- package/docs/agent.md +398 -0
- package/docs/architecture.md +196 -0
- package/docs/context-management.md +200 -0
- package/docs/extensions.md +951 -0
- package/docs/library.md +84 -0
- package/docs/troubleshooting.md +65 -0
- package/docs/tui-composition.md +294 -0
- package/docs/usage.md +306 -0
- package/examples/extensions/ash-acp-bridge/src/index.ts +11 -7
- package/examples/extensions/ash-scheme/index.ts +104 -74
- package/examples/extensions/ash-scheme/package.json +1 -1
- package/examples/extensions/ashi/EXTENDING.md +4 -2
- package/examples/extensions/ashi/README.md +17 -1
- package/examples/extensions/ashi/docs/ui-surface-protocol.md +163 -0
- package/examples/extensions/ashi/package.json +13 -3
- package/examples/extensions/ashi/src/capture.ts +45 -7
- package/examples/extensions/ashi/src/chat/assistant.ts +23 -43
- package/examples/extensions/ashi/src/chat/lines.ts +20 -1
- package/examples/extensions/ashi/src/cli.ts +26 -3
- package/examples/extensions/ashi/src/clipboard-image.ts +1 -1
- package/examples/extensions/ashi/src/dialogs.ts +67 -0
- package/examples/extensions/ashi/src/display-config.ts +7 -0
- package/examples/extensions/ashi/src/docks.ts +31 -0
- package/examples/extensions/ashi/src/events.ts +16 -0
- package/examples/extensions/ashi/src/frontend.ts +134 -27
- package/examples/extensions/ashi/src/hooks.ts +6 -12
- package/examples/extensions/ashi/src/input-prompt.ts +64 -0
- package/examples/extensions/ashi/src/renderer.ts +22 -2
- package/examples/extensions/ashi/src/renderers/pi-tui/index.ts +7 -3
- package/examples/extensions/ashi/src/renderers/pi-tui/nodes.ts +67 -10
- package/examples/extensions/ashi/src/renderers/pi-tui/schema-mount.ts +11 -1
- package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +5 -8
- package/examples/extensions/ashi/src/schema.ts +3 -0
- package/examples/extensions/ashi/src/session-commands.ts +2 -1
- package/examples/extensions/ashi/src/status-footer.ts +21 -3
- package/examples/extensions/ashi/src/ui.ts +88 -0
- package/examples/extensions/ashi-ink/README.md +2 -0
- package/examples/extensions/ashi-ink/package.json +2 -2
- package/examples/extensions/ashi-scheme-render.ts +8 -2
- package/examples/extensions/ashi-ui-demo.ts +63 -0
- package/examples/extensions/claude-code-bridge/package.json +1 -1
- package/examples/extensions/latex-images.ts +57 -9
- package/examples/extensions/opencode-bridge/package.json +1 -1
- package/examples/extensions/overlay-agent.ts +5 -5
- package/examples/extensions/pi-bridge/index.ts +7 -12
- package/package.json +3 -1
- package/src/agent/agent-loop.ts +1563 -0
- package/src/agent/entry-format.ts +19 -0
- package/src/agent/events.ts +151 -0
- package/src/agent/extensions/rolling-history/constants.ts +1 -0
- package/src/agent/extensions/rolling-history/index.ts +202 -0
- package/src/agent/extensions/rolling-history/recall.ts +131 -0
- package/src/agent/extensions/rolling-history/strategy.ts +404 -0
- package/src/agent/host-types.ts +192 -0
- package/src/agent/index.ts +591 -0
- package/src/agent/live-view.ts +279 -0
- package/src/agent/llm-client.ts +111 -0
- package/src/agent/llm-facade.ts +43 -0
- package/src/agent/normalize-args.ts +61 -0
- package/src/agent/nuclear-form.ts +382 -0
- package/src/agent/providers/deepseek.ts +39 -0
- package/src/agent/providers/ollama.ts +92 -0
- package/src/agent/providers/openai-compatible.ts +36 -0
- package/src/agent/providers/openai.ts +52 -0
- package/src/agent/providers/opencode.ts +142 -0
- package/src/agent/providers/openrouter.ts +105 -0
- package/src/agent/providers/zai-coding-plan.ts +33 -0
- package/src/agent/session-store.ts +336 -0
- package/src/agent/skills.ts +228 -0
- package/src/agent/store.ts +310 -0
- package/src/agent/subagent.ts +305 -0
- package/src/agent/system-prompt.ts +151 -0
- package/src/agent/token-budget.ts +12 -0
- package/src/agent/tool-protocol.ts +722 -0
- package/src/agent/tool-registry.ts +66 -0
- package/src/agent/tools/bash.ts +95 -0
- package/src/agent/tools/edit-file.ts +154 -0
- package/src/agent/tools/expand-home.ts +7 -0
- package/src/agent/tools/glob.ts +108 -0
- package/src/agent/tools/grep.ts +228 -0
- package/src/agent/tools/list-skills.ts +37 -0
- package/src/agent/tools/ls.ts +81 -0
- package/src/agent/tools/pwsh.ts +140 -0
- package/src/agent/tools/read-file.ts +164 -0
- package/src/agent/tools/write-file.ts +72 -0
- package/src/agent/types.ts +149 -0
- package/src/cli/args.ts +91 -0
- package/src/cli/auth/cli.ts +244 -0
- package/src/cli/auth/discover.ts +52 -0
- package/src/cli/auth/keys.ts +143 -0
- package/src/cli/index.ts +295 -0
- package/src/cli/init.ts +74 -0
- package/src/cli/install.ts +439 -0
- package/src/cli/shell-env.ts +68 -0
- package/src/cli/subcommands.ts +24 -0
- package/src/core/event-bus.ts +252 -0
- package/src/core/extension-loader.ts +347 -0
- package/src/core/index.ts +152 -0
- package/src/core/settings.ts +398 -0
- package/src/core/types.ts +61 -0
- package/src/extensions/file-autocomplete.ts +71 -0
- package/src/extensions/index.ts +38 -0
- package/src/extensions/slash-commands/events.ts +14 -0
- package/src/extensions/slash-commands/index.ts +269 -0
- package/src/shell/events.ts +73 -0
- package/src/shell/host-types.ts +150 -0
- package/src/shell/index.ts +159 -0
- package/src/shell/input-handler.ts +505 -0
- package/src/shell/output-parser.ts +156 -0
- package/src/shell/shell-context.ts +193 -0
- package/src/shell/shell.ts +414 -0
- package/src/shell/strategies/bash.ts +83 -0
- package/src/shell/strategies/fish.ts +77 -0
- package/src/shell/strategies/index.ts +24 -0
- package/src/shell/strategies/types.ts +64 -0
- package/src/shell/strategies/zsh.ts +92 -0
- package/src/shell/terminal.ts +124 -0
- package/src/shell/tui-input-view.ts +222 -0
- package/src/shell/tui-renderer.ts +1126 -0
- package/src/utils/ansi.ts +140 -0
- package/src/utils/box-frame.ts +138 -0
- package/src/utils/compositor.ts +157 -0
- package/src/utils/diff-renderer.ts +829 -0
- package/src/utils/diff.ts +244 -0
- package/src/utils/executor.ts +305 -0
- package/src/utils/file-watcher.ts +110 -0
- package/src/utils/floating-panel.ts +1160 -0
- package/src/utils/handler-registry.ts +110 -0
- package/src/utils/line-editor.ts +636 -0
- package/src/utils/markdown.ts +437 -0
- package/src/utils/message-utils.ts +113 -0
- package/src/utils/package-version.ts +12 -0
- package/src/utils/palette.ts +64 -0
- package/src/utils/ref-counter.ts +9 -0
- package/src/utils/ripgrep-path.ts +17 -0
- package/src/utils/shell-output-spill.ts +76 -0
- package/src/utils/stream-transform.ts +292 -0
- package/src/utils/terminal-buffer.ts +213 -0
- package/src/utils/tool-display.ts +315 -0
- package/src/utils/tool-interactive.ts +71 -0
- 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
|
+
}
|