@compilr-dev/cli 0.4.0 → 0.5.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/README.md +30 -12
- package/dist/agent.d.ts +74 -1
- package/dist/agent.js +259 -76
- package/dist/anchors/index.d.ts +9 -0
- package/dist/anchors/index.js +9 -0
- package/dist/anchors/project-anchors.d.ts +79 -0
- package/dist/anchors/project-anchors.js +202 -0
- package/dist/commands/handler-types.d.ts +68 -0
- package/dist/commands/handler-types.js +8 -0
- package/dist/commands/handlers/agent-commands.d.ts +13 -0
- package/dist/commands/handlers/agent-commands.js +305 -0
- package/dist/commands/handlers/design-commands.d.ts +15 -0
- package/dist/commands/handlers/design-commands.js +334 -0
- package/dist/commands/handlers/index.d.ts +20 -0
- package/dist/commands/handlers/index.js +43 -0
- package/dist/commands/handlers/overlay-commands.d.ts +21 -0
- package/dist/commands/handlers/overlay-commands.js +287 -0
- package/dist/commands/handlers/project-commands.d.ts +11 -0
- package/dist/commands/handlers/project-commands.js +167 -0
- package/dist/commands/handlers/simple-commands.d.ts +19 -0
- package/dist/commands/handlers/simple-commands.js +144 -0
- package/dist/commands/index.d.ts +2 -1
- package/dist/commands/registry.d.ts +50 -0
- package/dist/commands/registry.js +75 -0
- package/dist/commands-v2/handlers/context.d.ts +13 -0
- package/dist/commands-v2/handlers/context.js +348 -0
- package/dist/commands-v2/handlers/core.d.ts +13 -0
- package/dist/commands-v2/handlers/core.js +165 -0
- package/dist/commands-v2/handlers/debug.d.ts +11 -0
- package/dist/commands-v2/handlers/debug.js +159 -0
- package/dist/commands-v2/handlers/index.d.ts +12 -0
- package/dist/commands-v2/handlers/index.js +24 -0
- package/dist/commands-v2/handlers/project.d.ts +22 -0
- package/dist/commands-v2/handlers/project.js +814 -0
- package/dist/commands-v2/handlers/settings.d.ts +15 -0
- package/dist/commands-v2/handlers/settings.js +235 -0
- package/dist/commands-v2/index.d.ts +13 -0
- package/dist/commands-v2/index.js +15 -0
- package/dist/commands-v2/registry.d.ts +37 -0
- package/dist/commands-v2/registry.js +80 -0
- package/dist/commands-v2/types.d.ts +75 -0
- package/dist/commands-v2/types.js +7 -0
- package/dist/commands.js +110 -7
- package/dist/index.js +288 -29
- package/dist/input-handlers/index.d.ts +7 -0
- package/dist/input-handlers/index.js +7 -0
- package/dist/input-handlers/memory-handler.d.ts +26 -0
- package/dist/input-handlers/memory-handler.js +68 -0
- package/dist/repl-helpers.d.ts +63 -0
- package/dist/repl-helpers.js +318 -0
- package/dist/repl-v2.d.ts +155 -0
- package/dist/repl-v2.js +774 -0
- package/dist/repl.d.ts +32 -4
- package/dist/repl.js +250 -977
- package/dist/settings/index.d.ts +23 -0
- package/dist/settings/index.js +48 -0
- package/dist/settings/paths.d.ts +110 -0
- package/dist/settings/paths.js +264 -0
- package/dist/templates/compilr-md.js +7 -4
- package/dist/templates/index.js +3 -4
- package/dist/themes/colors.js +3 -1
- package/dist/themes/registry.d.ts +5 -36
- package/dist/themes/registry.js +11 -95
- package/dist/themes/types.d.ts +3 -38
- package/dist/themes/types.js +2 -2
- package/dist/tools/anchor-tools.d.ts +31 -0
- package/dist/tools/anchor-tools.js +255 -0
- package/dist/tools/backlog-wrappers.d.ts +54 -0
- package/dist/tools/backlog-wrappers.js +338 -0
- package/dist/tools/backlog.js +1 -1
- package/dist/tools/db-tools.d.ts +65 -0
- package/dist/tools/db-tools.js +19 -0
- package/dist/tools/document-db.d.ts +43 -0
- package/dist/tools/document-db.js +220 -0
- package/dist/tools/project-db.d.ts +102 -0
- package/dist/tools/project-db.js +370 -0
- package/dist/tools/workitem-db.d.ts +103 -0
- package/dist/tools/workitem-db.js +549 -0
- package/dist/tools.js +13 -3
- package/dist/ui/agents-overlay-v2.d.ts +43 -0
- package/dist/ui/agents-overlay-v2.js +809 -0
- package/dist/ui/agents-overlay.d.ts +5 -5
- package/dist/ui/agents-overlay.js +782 -420
- package/dist/ui/anchors-overlay.d.ts +12 -0
- package/dist/ui/anchors-overlay.js +775 -0
- package/dist/ui/arch-type-overlay.d.ts +1 -6
- package/dist/ui/arch-type-overlay.js +175 -203
- package/dist/ui/ask-user-overlay-v2.d.ts +26 -0
- package/dist/ui/ask-user-overlay-v2.js +555 -0
- package/dist/ui/ask-user-overlay.d.ts +2 -2
- package/dist/ui/ask-user-overlay.js +443 -535
- package/dist/ui/ask-user-simple-overlay-v2.d.ts +25 -0
- package/dist/ui/ask-user-simple-overlay-v2.js +215 -0
- package/dist/ui/ask-user-simple-overlay.d.ts +2 -2
- package/dist/ui/ask-user-simple-overlay.js +182 -209
- package/dist/ui/backlog-overlay.d.ts +16 -1
- package/dist/ui/backlog-overlay.js +525 -659
- package/dist/ui/base/index.d.ts +26 -0
- package/dist/ui/base/index.js +33 -0
- package/dist/ui/base/inline-overlay-utils.d.ts +217 -0
- package/dist/ui/base/inline-overlay-utils.js +320 -0
- package/dist/ui/base/inline-overlay.d.ts +159 -0
- package/dist/ui/base/inline-overlay.js +257 -0
- package/dist/ui/base/key-utils.d.ts +15 -0
- package/dist/ui/base/key-utils.js +30 -0
- package/dist/ui/base/overlay-base-v2.d.ts +193 -0
- package/dist/ui/base/overlay-base-v2.js +246 -0
- package/dist/ui/base/overlay-base.d.ts +156 -0
- package/dist/ui/base/overlay-base.js +238 -0
- package/dist/ui/base/overlay-lifecycle.d.ts +65 -0
- package/dist/ui/base/overlay-lifecycle.js +159 -0
- package/dist/ui/base/overlay-types.d.ts +185 -0
- package/dist/ui/base/overlay-types.js +7 -0
- package/dist/ui/base/render-utils.d.ts +8 -0
- package/dist/ui/base/render-utils.js +11 -0
- package/dist/ui/base/screen-stack.d.ts +148 -0
- package/dist/ui/base/screen-stack.js +184 -0
- package/dist/ui/base/tabbed-list-overlay-v2.d.ts +103 -0
- package/dist/ui/base/tabbed-list-overlay-v2.js +317 -0
- package/dist/ui/base/tabbed-list-overlay.d.ts +153 -0
- package/dist/ui/base/tabbed-list-overlay.js +369 -0
- package/dist/ui/commands-overlay-v2.d.ts +33 -0
- package/dist/ui/commands-overlay-v2.js +441 -0
- package/dist/ui/commands-overlay.d.ts +7 -2
- package/dist/ui/commands-overlay.js +384 -355
- package/dist/ui/config-overlay.d.ts +5 -4
- package/dist/ui/config-overlay.js +243 -513
- package/dist/ui/conversation.d.ts +75 -4
- package/dist/ui/conversation.js +374 -161
- package/dist/ui/docs-overlay.d.ts +17 -0
- package/dist/ui/docs-overlay.js +303 -0
- package/dist/ui/ephemeral.d.ts +1 -1
- package/dist/ui/ephemeral.js +1 -1
- package/dist/ui/features/index.d.ts +34 -0
- package/dist/ui/features/index.js +34 -0
- package/dist/ui/features/input-feature.d.ts +85 -0
- package/dist/ui/features/input-feature.js +238 -0
- package/dist/ui/features/list-feature.d.ts +155 -0
- package/dist/ui/features/list-feature.js +244 -0
- package/dist/ui/features/pagination-feature.d.ts +154 -0
- package/dist/ui/features/pagination-feature.js +238 -0
- package/dist/ui/features/search-feature.d.ts +148 -0
- package/dist/ui/features/search-feature.js +185 -0
- package/dist/ui/features/tab-feature.d.ts +194 -0
- package/dist/ui/features/tab-feature.js +307 -0
- package/dist/ui/footer-v2.d.ts +222 -0
- package/dist/ui/footer-v2.js +1349 -0
- package/dist/ui/footer.d.ts +107 -0
- package/dist/ui/footer.js +359 -67
- package/dist/ui/guardrail-overlay.d.ts +29 -0
- package/dist/ui/guardrail-overlay.js +145 -0
- package/dist/ui/help-overlay-v2.d.ts +34 -0
- package/dist/ui/help-overlay-v2.js +309 -0
- package/dist/ui/help-overlay.d.ts +16 -0
- package/dist/ui/help-overlay.js +316 -0
- package/dist/ui/index.d.ts +1 -1
- package/dist/ui/index.js +1 -3
- package/dist/ui/init-overlay-v2.d.ts +34 -0
- package/dist/ui/init-overlay-v2.js +600 -0
- package/dist/ui/init-overlay.d.ts +12 -2
- package/dist/ui/init-overlay.js +349 -270
- package/dist/ui/input-prompt-v2.d.ts +1 -0
- package/dist/ui/input-prompt-v2.js +14 -6
- package/dist/ui/input-prompt.d.ts +116 -33
- package/dist/ui/input-prompt.js +536 -337
- package/dist/ui/iteration-limit-overlay-v2.d.ts +21 -0
- package/dist/ui/iteration-limit-overlay-v2.js +114 -0
- package/dist/ui/iteration-limit-overlay.d.ts +2 -2
- package/dist/ui/iteration-limit-overlay.js +92 -128
- package/dist/ui/keys-overlay-v2.d.ts +41 -0
- package/dist/ui/keys-overlay-v2.js +248 -0
- package/dist/ui/keys-overlay.d.ts +1 -0
- package/dist/ui/keys-overlay.js +203 -141
- package/dist/ui/line-utils.d.ts +88 -0
- package/dist/ui/line-utils.js +150 -0
- package/dist/ui/live-region.d.ts +161 -0
- package/dist/ui/live-region.js +387 -0
- package/dist/ui/mascot/expressions.d.ts +32 -0
- package/dist/ui/mascot/expressions.js +213 -0
- package/dist/ui/mascot/index.d.ts +8 -0
- package/dist/ui/mascot/index.js +8 -0
- package/dist/ui/mascot/renderer.d.ts +19 -0
- package/dist/ui/mascot/renderer.js +97 -0
- package/dist/ui/mascot-overlay-v2.d.ts +41 -0
- package/dist/ui/mascot-overlay-v2.js +138 -0
- package/dist/ui/mascot-overlay.d.ts +21 -0
- package/dist/ui/mascot-overlay.js +146 -0
- package/dist/ui/model-overlay-v2.d.ts +49 -0
- package/dist/ui/model-overlay-v2.js +118 -0
- package/dist/ui/model-overlay.d.ts +27 -0
- package/dist/ui/model-overlay.js +221 -0
- package/dist/ui/model-warning-overlay.js +3 -5
- package/dist/ui/new-overlay.d.ts +34 -0
- package/dist/ui/new-overlay.js +604 -0
- package/dist/ui/overlay/impl/agents-overlay-v2.d.ts +45 -0
- package/dist/ui/overlay/impl/agents-overlay-v2.js +825 -0
- package/dist/ui/overlay/impl/anchors-overlay-v2.d.ts +47 -0
- package/dist/ui/overlay/impl/anchors-overlay-v2.js +783 -0
- package/dist/ui/overlay/impl/arch-type-overlay-v2.d.ts +37 -0
- package/dist/ui/overlay/impl/arch-type-overlay-v2.js +240 -0
- package/dist/ui/overlay/impl/ask-user-overlay-v2.d.ts +72 -0
- package/dist/ui/overlay/impl/ask-user-overlay-v2.js +584 -0
- package/dist/ui/overlay/impl/ask-user-simple-overlay-v2.d.ts +46 -0
- package/dist/ui/overlay/impl/ask-user-simple-overlay-v2.js +204 -0
- package/dist/ui/overlay/impl/backlog-overlay-v2.d.ts +49 -0
- package/dist/ui/overlay/impl/backlog-overlay-v2.js +642 -0
- package/dist/ui/overlay/impl/commands-overlay-v2.d.ts +33 -0
- package/dist/ui/overlay/impl/commands-overlay-v2.js +441 -0
- package/dist/ui/overlay/impl/config-overlay-v2.d.ts +100 -0
- package/dist/ui/overlay/impl/config-overlay-v2.js +654 -0
- package/dist/ui/overlay/impl/dashboard-overlay-v2.d.ts +55 -0
- package/dist/ui/overlay/impl/dashboard-overlay-v2.js +359 -0
- package/dist/ui/overlay/impl/docs-overlay-v2.d.ts +45 -0
- package/dist/ui/overlay/impl/docs-overlay-v2.js +114 -0
- package/dist/ui/overlay/impl/document-detail-overlay-v2.d.ts +77 -0
- package/dist/ui/overlay/impl/document-detail-overlay-v2.js +1071 -0
- package/dist/ui/overlay/impl/guardrail-overlay-v2.d.ts +43 -0
- package/dist/ui/overlay/impl/guardrail-overlay-v2.js +114 -0
- package/dist/ui/overlay/impl/help-overlay-v2.d.ts +34 -0
- package/dist/ui/overlay/impl/help-overlay-v2.js +309 -0
- package/dist/ui/overlay/impl/init-overlay-v2.d.ts +77 -0
- package/dist/ui/overlay/impl/init-overlay-v2.js +593 -0
- package/dist/ui/overlay/impl/init-setup-overlay-v2.d.ts +25 -0
- package/dist/ui/overlay/impl/init-setup-overlay-v2.js +97 -0
- package/dist/ui/overlay/impl/iteration-limit-overlay-v2.d.ts +35 -0
- package/dist/ui/overlay/impl/iteration-limit-overlay-v2.js +105 -0
- package/dist/ui/overlay/impl/keys-overlay-v2.d.ts +41 -0
- package/dist/ui/overlay/impl/keys-overlay-v2.js +248 -0
- package/dist/ui/overlay/impl/mascot-overlay-v2.d.ts +41 -0
- package/dist/ui/overlay/impl/mascot-overlay-v2.js +138 -0
- package/dist/ui/overlay/impl/model-overlay-v2.d.ts +49 -0
- package/dist/ui/overlay/impl/model-overlay-v2.js +118 -0
- package/dist/ui/overlay/impl/model-warning-overlay-v2.d.ts +46 -0
- package/dist/ui/overlay/impl/model-warning-overlay-v2.js +132 -0
- package/dist/ui/overlay/impl/new-overlay-v2.d.ts +77 -0
- package/dist/ui/overlay/impl/new-overlay-v2.js +593 -0
- package/dist/ui/overlay/impl/permission-overlay-v2.d.ts +36 -0
- package/dist/ui/overlay/impl/permission-overlay-v2.js +380 -0
- package/dist/ui/overlay/impl/projects-overlay-v2.d.ts +36 -0
- package/dist/ui/overlay/impl/projects-overlay-v2.js +499 -0
- package/dist/ui/overlay/impl/theme-overlay-v2.d.ts +42 -0
- package/dist/ui/overlay/impl/theme-overlay-v2.js +135 -0
- package/dist/ui/overlay/impl/tools-overlay-v2.d.ts +47 -0
- package/dist/ui/overlay/impl/tools-overlay-v2.js +218 -0
- package/dist/ui/overlay/impl/tutorial-overlay-v2.d.ts +31 -0
- package/dist/ui/overlay/impl/tutorial-overlay-v2.js +1035 -0
- package/dist/ui/overlay/impl/workflow-overlay-v2.d.ts +80 -0
- package/dist/ui/overlay/impl/workflow-overlay-v2.js +637 -0
- package/dist/ui/overlay/index.d.ts +33 -0
- package/dist/ui/overlay/index.js +35 -0
- package/dist/ui/overlay/key-utils.d.ts +6 -0
- package/dist/ui/overlay/key-utils.js +6 -0
- package/dist/ui/overlay/overlay-types.d.ts +128 -0
- package/dist/ui/overlay/overlay-types.js +22 -0
- package/dist/ui/overlay/types.d.ts +135 -0
- package/dist/ui/overlay/types.js +22 -0
- package/dist/ui/overlays/help-overlay-v2.d.ts +28 -0
- package/dist/ui/overlays/help-overlay-v2.js +198 -0
- package/dist/ui/overlays/index.d.ts +11 -0
- package/dist/ui/overlays/index.js +11 -0
- package/dist/ui/overlays.d.ts +0 -4
- package/dist/ui/overlays.js +0 -444
- package/dist/ui/permission-overlay-v2.d.ts +36 -0
- package/dist/ui/permission-overlay-v2.js +380 -0
- package/dist/ui/permission-overlay.d.ts +1 -1
- package/dist/ui/permission-overlay.js +186 -298
- package/dist/ui/projects-overlay.d.ts +19 -0
- package/dist/ui/projects-overlay.js +484 -0
- package/dist/ui/providers/types.d.ts +178 -0
- package/dist/ui/providers/types.js +9 -0
- package/dist/ui/render-modes.d.ts +36 -0
- package/dist/ui/render-modes.js +44 -0
- package/dist/ui/startup-menu.d.ts +36 -0
- package/dist/ui/startup-menu.js +236 -0
- package/dist/ui/subagent-renderer.d.ts +117 -0
- package/dist/ui/subagent-renderer.js +334 -0
- package/dist/ui/terminal-codes.d.ts +94 -0
- package/dist/ui/terminal-codes.js +124 -0
- package/dist/ui/terminal-renderer.d.ts +221 -0
- package/dist/ui/terminal-renderer.js +751 -0
- package/dist/ui/terminal-ui.d.ts +463 -0
- package/dist/ui/terminal-ui.js +2296 -0
- package/dist/ui/terminal.d.ts +20 -0
- package/dist/ui/terminal.js +72 -0
- package/dist/ui/theme-overlay-v2.d.ts +42 -0
- package/dist/ui/theme-overlay-v2.js +135 -0
- package/dist/ui/theme-overlay.d.ts +24 -0
- package/dist/ui/theme-overlay.js +127 -0
- package/dist/ui/todo-zone.js +53 -25
- package/dist/ui/tool-formatters.d.ts +16 -0
- package/dist/ui/tool-formatters.js +516 -0
- package/dist/ui/tools-overlay-v2.d.ts +47 -0
- package/dist/ui/tools-overlay-v2.js +218 -0
- package/dist/ui/tools-overlay.d.ts +10 -2
- package/dist/ui/tools-overlay.js +172 -220
- package/dist/ui/tutorial-overlay-v2.d.ts +31 -0
- package/dist/ui/tutorial-overlay-v2.js +1035 -0
- package/dist/ui/tutorial-overlay.d.ts +1 -0
- package/dist/ui/tutorial-overlay.js +400 -302
- package/dist/ui/workflow-overlay.d.ts +22 -0
- package/dist/ui/workflow-overlay.js +636 -0
- package/dist/utils/debug-log.d.ts +28 -0
- package/dist/utils/debug-log.js +57 -0
- package/dist/utils/model-tiers.js +1 -1
- package/dist/utils/path-safety.d.ts +56 -0
- package/dist/utils/path-safety.js +239 -0
- package/dist/workflow/guided-mode-injector.d.ts +42 -0
- package/dist/workflow/guided-mode-injector.js +191 -0
- package/dist/workflow/index.d.ts +8 -0
- package/dist/workflow/index.js +8 -0
- package/dist/workflow/step-criteria.d.ts +62 -0
- package/dist/workflow/step-criteria.js +150 -0
- package/dist/workflow/step-tracker.d.ts +92 -0
- package/dist/workflow/step-tracker.js +141 -0
- package/package.json +12 -5
|
@@ -0,0 +1,2296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TerminalUI - Single Renderer for Terminal Applications
|
|
3
|
+
*
|
|
4
|
+
* Single point of control for all terminal UI:
|
|
5
|
+
* - Owns all rendering decisions (when to clear, when to render)
|
|
6
|
+
* - Owns all UI state (agentRunning, todos, currentTool, etc.)
|
|
7
|
+
* - Provides output methods that handle clear/render automatically
|
|
8
|
+
* - Manages overlays (render, input routing)
|
|
9
|
+
* - REPL emits events, doesn't manage rendering timing
|
|
10
|
+
*
|
|
11
|
+
* Key principles:
|
|
12
|
+
* - NO scroll regions (preserves terminal scrollback)
|
|
13
|
+
* - Deterministic render cycle
|
|
14
|
+
* - All output goes through TerminalUI (no external console.log)
|
|
15
|
+
*/
|
|
16
|
+
import { EventEmitter } from 'events';
|
|
17
|
+
import * as readline from 'readline';
|
|
18
|
+
import * as terminal from './terminal.js';
|
|
19
|
+
import { getPhysicalLineCount, getVisibleLength } from './line-utils.js';
|
|
20
|
+
import { getStyles } from '../themes/index.js';
|
|
21
|
+
import { MODE_INFO } from './types.js';
|
|
22
|
+
import { getAutocompleteCommands } from '../commands.js';
|
|
23
|
+
import { getFileMatches, extractAtMention, replaceAtMention, } from './file-autocomplete.js';
|
|
24
|
+
import { LiveRegion } from './live-region.js';
|
|
25
|
+
import { renderMarkdown } from './conversation.js';
|
|
26
|
+
export const DEFAULT_TERMINAL_UI_CONFIG = {
|
|
27
|
+
verbose: false,
|
|
28
|
+
theme: 'default',
|
|
29
|
+
showMascot: true,
|
|
30
|
+
};
|
|
31
|
+
// =============================================================================
|
|
32
|
+
// Constants
|
|
33
|
+
// =============================================================================
|
|
34
|
+
const MAX_VISIBLE_COMMANDS = 10;
|
|
35
|
+
// =============================================================================
|
|
36
|
+
// Mascot Expressions
|
|
37
|
+
// =============================================================================
|
|
38
|
+
/**
|
|
39
|
+
* Inline mascot expressions for agent identity and state feedback.
|
|
40
|
+
* Used to prefix agent messages and in spinner animations.
|
|
41
|
+
*/
|
|
42
|
+
export const MASCOT = {
|
|
43
|
+
// Core expressions
|
|
44
|
+
neutral: '[•_•]', // Default state - regular messages
|
|
45
|
+
thinking: '[°_°]', // Processing/thinking
|
|
46
|
+
searching: '[◐_◐]', // Searching/scanning files
|
|
47
|
+
success: '[^_^]', // Task completed successfully
|
|
48
|
+
error: '[×_×]', // Error occurred
|
|
49
|
+
confused: '[?_?]', // Needs clarification
|
|
50
|
+
working: '[•̀_•́]', // Actively working on task
|
|
51
|
+
// CRT monitor animation frames (subtle scanner effect)
|
|
52
|
+
crt: ['[░░░]', '[▒░░]', '[░▒░]', '[░░▒]', '[░▒░]', '[▒░░]'],
|
|
53
|
+
};
|
|
54
|
+
// =============================================================================
|
|
55
|
+
// Fuzzy Matching
|
|
56
|
+
// =============================================================================
|
|
57
|
+
/**
|
|
58
|
+
* Calculate fuzzy match score for a query against a target string.
|
|
59
|
+
* Higher score = better match. Returns -1 if no match.
|
|
60
|
+
*/
|
|
61
|
+
function fuzzyMatchScore(query, target) {
|
|
62
|
+
const queryLower = query.toLowerCase();
|
|
63
|
+
const targetLower = target.toLowerCase();
|
|
64
|
+
// Exact prefix match - highest priority (score 1000+)
|
|
65
|
+
if (targetLower.startsWith(queryLower)) {
|
|
66
|
+
return 1000 + (100 - target.length); // Shorter commands rank higher
|
|
67
|
+
}
|
|
68
|
+
// Contiguous substring match - high priority (score 500+)
|
|
69
|
+
if (targetLower.includes(queryLower)) {
|
|
70
|
+
const index = targetLower.indexOf(queryLower);
|
|
71
|
+
return 500 + (100 - index); // Earlier matches rank higher
|
|
72
|
+
}
|
|
73
|
+
// Fuzzy match - characters appear in order (score 100+)
|
|
74
|
+
let queryIdx = 0;
|
|
75
|
+
let consecutiveBonus = 0;
|
|
76
|
+
let lastMatchIdx = -1;
|
|
77
|
+
for (let i = 0; i < targetLower.length && queryIdx < queryLower.length; i++) {
|
|
78
|
+
if (targetLower[i] === queryLower[queryIdx]) {
|
|
79
|
+
if (lastMatchIdx === i - 1) {
|
|
80
|
+
consecutiveBonus += 10;
|
|
81
|
+
}
|
|
82
|
+
lastMatchIdx = i;
|
|
83
|
+
queryIdx++;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (queryIdx === queryLower.length) {
|
|
87
|
+
return 100 + consecutiveBonus + (100 - target.length);
|
|
88
|
+
}
|
|
89
|
+
return -1;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Filter and rank commands matching input using fuzzy matching
|
|
93
|
+
*/
|
|
94
|
+
function filterCommands(input, commands) {
|
|
95
|
+
const scored = commands
|
|
96
|
+
.map((cmd) => ({
|
|
97
|
+
cmd,
|
|
98
|
+
score: fuzzyMatchScore(input, cmd.command),
|
|
99
|
+
}))
|
|
100
|
+
.filter((item) => item.score >= 0);
|
|
101
|
+
scored.sort((a, b) => b.score - a.score);
|
|
102
|
+
return scored.map((item) => item.cmd);
|
|
103
|
+
}
|
|
104
|
+
// =============================================================================
|
|
105
|
+
// Footer V2 Class
|
|
106
|
+
// =============================================================================
|
|
107
|
+
export class TerminalUI extends EventEmitter {
|
|
108
|
+
// Configuration
|
|
109
|
+
promptPrefix;
|
|
110
|
+
promptPrefixLen;
|
|
111
|
+
config;
|
|
112
|
+
// Conversation history (for re-rendering on config change)
|
|
113
|
+
conversationHistory = [];
|
|
114
|
+
// Input state (multiline)
|
|
115
|
+
lines = [''];
|
|
116
|
+
currentLine = 0;
|
|
117
|
+
cursorPos = 0;
|
|
118
|
+
mode;
|
|
119
|
+
projectName = null;
|
|
120
|
+
todos = [];
|
|
121
|
+
spinnerText = null;
|
|
122
|
+
spinnerFrame = 0;
|
|
123
|
+
agentRunning = false;
|
|
124
|
+
queuedInputs = [];
|
|
125
|
+
agentMessageQueue = [];
|
|
126
|
+
currentTool = null;
|
|
127
|
+
// LiveRegion for running tools (bash, subagents)
|
|
128
|
+
liveRegion = new LiveRegion();
|
|
129
|
+
// Command autocomplete state (for /commands)
|
|
130
|
+
autocomplete = {
|
|
131
|
+
active: false,
|
|
132
|
+
matches: [],
|
|
133
|
+
selectedIndex: 0,
|
|
134
|
+
scrollOffset: 0,
|
|
135
|
+
};
|
|
136
|
+
// File autocomplete state (for @paths)
|
|
137
|
+
fileAutocomplete = {
|
|
138
|
+
active: false,
|
|
139
|
+
matches: [],
|
|
140
|
+
selectedIndex: 0,
|
|
141
|
+
scrollOffset: 0,
|
|
142
|
+
partial: '',
|
|
143
|
+
};
|
|
144
|
+
// History state
|
|
145
|
+
history = [];
|
|
146
|
+
historyIndex = -1;
|
|
147
|
+
savedInput = '';
|
|
148
|
+
// Render tracking
|
|
149
|
+
lastRenderHeight = 0;
|
|
150
|
+
cursorLineFromBottom = 0;
|
|
151
|
+
isRunning = false;
|
|
152
|
+
isPaused = false;
|
|
153
|
+
renderTimer = null;
|
|
154
|
+
needsRender = false;
|
|
155
|
+
// Double Esc detection
|
|
156
|
+
lastEscapeTime = 0;
|
|
157
|
+
// Ghost text suggestion
|
|
158
|
+
suggestion = null;
|
|
159
|
+
// Todo visibility (Ctrl+T to toggle)
|
|
160
|
+
showTodos = true;
|
|
161
|
+
// View mode (Ctrl+O to toggle verbose view)
|
|
162
|
+
// normal: compact output
|
|
163
|
+
// verbose-temp: temporarily show last N items verbose (any key returns to normal)
|
|
164
|
+
viewMode = 'normal';
|
|
165
|
+
// Spinner animation - CRT monitor fill effect
|
|
166
|
+
spinnerFrames = MASCOT.crt;
|
|
167
|
+
spinnerTimer = null;
|
|
168
|
+
// Overlay stack (supports nested overlays)
|
|
169
|
+
overlayStack = [];
|
|
170
|
+
overlayResolvers = new Map();
|
|
171
|
+
overlayRenderState = { lineCount: 0, maxLineCount: 0 };
|
|
172
|
+
// Buffer for items printed while overlay is active (to prevent cursor corruption)
|
|
173
|
+
overlayPrintBuffer = [];
|
|
174
|
+
constructor(options = {}) {
|
|
175
|
+
super();
|
|
176
|
+
const s = getStyles();
|
|
177
|
+
this.promptPrefix = options.prompt ?? s.primaryBold('compilr>') + ' ';
|
|
178
|
+
this.promptPrefixLen = getVisibleLength(this.promptPrefix);
|
|
179
|
+
this.mode = options.initialMode ?? 'normal';
|
|
180
|
+
this.config = { ...DEFAULT_TERMINAL_UI_CONFIG, ...options.config };
|
|
181
|
+
}
|
|
182
|
+
// ===========================================================================
|
|
183
|
+
// Input value helpers
|
|
184
|
+
// ===========================================================================
|
|
185
|
+
/** Get the full input value (all lines joined with newlines) */
|
|
186
|
+
getInputValue() {
|
|
187
|
+
return this.lines.join('\n');
|
|
188
|
+
}
|
|
189
|
+
/** Get the current line content */
|
|
190
|
+
getCurrentLineContent() {
|
|
191
|
+
return this.lines[this.currentLine];
|
|
192
|
+
}
|
|
193
|
+
/** Clear input and reset to single empty line */
|
|
194
|
+
clearInput() {
|
|
195
|
+
this.lines = [''];
|
|
196
|
+
this.currentLine = 0;
|
|
197
|
+
this.cursorPos = 0;
|
|
198
|
+
}
|
|
199
|
+
// ===========================================================================
|
|
200
|
+
// Lifecycle
|
|
201
|
+
// ===========================================================================
|
|
202
|
+
start() {
|
|
203
|
+
if (this.isRunning)
|
|
204
|
+
return;
|
|
205
|
+
this.isRunning = true;
|
|
206
|
+
this.isPaused = false;
|
|
207
|
+
// Start keyboard input handling
|
|
208
|
+
this.startKeyboardCapture();
|
|
209
|
+
// Initial render
|
|
210
|
+
this.render();
|
|
211
|
+
// Start render loop (60ms = ~16fps)
|
|
212
|
+
this.renderTimer = setInterval(() => {
|
|
213
|
+
if (this.needsRender && !this.isPaused) {
|
|
214
|
+
this.render();
|
|
215
|
+
this.needsRender = false;
|
|
216
|
+
}
|
|
217
|
+
}, 60);
|
|
218
|
+
}
|
|
219
|
+
stop() {
|
|
220
|
+
if (!this.isRunning)
|
|
221
|
+
return;
|
|
222
|
+
this.isRunning = false;
|
|
223
|
+
// Stop render loop
|
|
224
|
+
if (this.renderTimer) {
|
|
225
|
+
clearInterval(this.renderTimer);
|
|
226
|
+
this.renderTimer = null;
|
|
227
|
+
}
|
|
228
|
+
// Stop spinner
|
|
229
|
+
this.stopSpinnerAnimation();
|
|
230
|
+
// Stop keyboard capture
|
|
231
|
+
this.stopKeyboardCapture();
|
|
232
|
+
// Clear footer
|
|
233
|
+
this.clear();
|
|
234
|
+
}
|
|
235
|
+
// ===========================================================================
|
|
236
|
+
// Public API - Output (THE KEY METHODS)
|
|
237
|
+
// ===========================================================================
|
|
238
|
+
/**
|
|
239
|
+
* Print a semantic item to the scrolling zone.
|
|
240
|
+
* Items are stored in conversation history for re-rendering when config changes.
|
|
241
|
+
*
|
|
242
|
+
* IMPORTANT: When an overlay is active, items are buffered and rendered after
|
|
243
|
+
* the overlay closes. This prevents cursor position corruption that causes
|
|
244
|
+
* ghost lines and visual artifacts.
|
|
245
|
+
*/
|
|
246
|
+
print(item) {
|
|
247
|
+
// Store in history for re-render capability
|
|
248
|
+
this.conversationHistory.push(item);
|
|
249
|
+
// If overlay is active, buffer the item to render later
|
|
250
|
+
// This prevents output from corrupting overlay cursor tracking
|
|
251
|
+
if (this.hasActiveOverlay()) {
|
|
252
|
+
this.overlayPrintBuffer.push(item);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
// IMPORTANT: If needsRender is true, render FIRST to update lastRenderHeight.
|
|
256
|
+
// This prevents ghost lines when footer height changed (e.g., spinner started)
|
|
257
|
+
// but render loop hasn't fired yet. Without this, clear() uses stale height.
|
|
258
|
+
if (this.needsRender) {
|
|
259
|
+
this.render();
|
|
260
|
+
this.needsRender = false;
|
|
261
|
+
}
|
|
262
|
+
// Clear footer, render item, re-render footer
|
|
263
|
+
this.clear();
|
|
264
|
+
this.renderItem(item);
|
|
265
|
+
this.render();
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Render a single item to the console.
|
|
269
|
+
* Config-aware: respects verbose, showMascot settings.
|
|
270
|
+
*/
|
|
271
|
+
renderItem(item) {
|
|
272
|
+
const s = getStyles();
|
|
273
|
+
switch (item.type) {
|
|
274
|
+
case 'user-message':
|
|
275
|
+
console.log('');
|
|
276
|
+
console.log(s.primaryBold('> ') + item.text);
|
|
277
|
+
console.log(''); // Trailing blank for separation
|
|
278
|
+
break;
|
|
279
|
+
case 'agent-text': {
|
|
280
|
+
// Render markdown for proper formatting (headers, lists, bold, code, etc.)
|
|
281
|
+
const rendered = renderMarkdown(item.text);
|
|
282
|
+
// Prefix agent messages with mascot expression (if enabled)
|
|
283
|
+
if (this.config.showMascot) {
|
|
284
|
+
const expr = item.expression ? MASCOT[item.expression] : MASCOT.neutral;
|
|
285
|
+
// Only prefix the first line with mascot
|
|
286
|
+
const lines = rendered.split('\n');
|
|
287
|
+
if (lines.length > 0) {
|
|
288
|
+
console.log(s.primary(expr) + s.muted(' > ') + lines[0]);
|
|
289
|
+
for (let i = 1; i < lines.length; i++) {
|
|
290
|
+
console.log(lines[i]);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
console.log(rendered);
|
|
296
|
+
}
|
|
297
|
+
console.log(''); // Trailing blank for separation
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
case 'thinking':
|
|
301
|
+
// Only show thinking in verbose mode
|
|
302
|
+
if (this.config.verbose) {
|
|
303
|
+
console.log(s.muted(`∴ Thinking…`));
|
|
304
|
+
console.log('');
|
|
305
|
+
// Indent thinking text
|
|
306
|
+
const lines = item.text.split('\n');
|
|
307
|
+
for (const line of lines) {
|
|
308
|
+
console.log(s.muted(` ${line}`));
|
|
309
|
+
}
|
|
310
|
+
console.log('');
|
|
311
|
+
}
|
|
312
|
+
// If not verbose, skip entirely (but still stored in history)
|
|
313
|
+
break;
|
|
314
|
+
case 'tool-start': {
|
|
315
|
+
// Truncate long params (e.g., long bash commands)
|
|
316
|
+
const maxLen = Math.min(60, terminal.getTerminalWidth() - 15);
|
|
317
|
+
let params = item.params;
|
|
318
|
+
// Handle multi-line params
|
|
319
|
+
const nlIdx = params.indexOf('\n');
|
|
320
|
+
if (nlIdx !== -1) {
|
|
321
|
+
const first = params.slice(0, nlIdx).trim();
|
|
322
|
+
const count = params.split('\n').length;
|
|
323
|
+
params = first.length > 0 ? `${first}… (${String(count)} lines)` : `(${String(count)} line script)`;
|
|
324
|
+
}
|
|
325
|
+
// Truncate if still too long
|
|
326
|
+
if (params.length > maxLen) {
|
|
327
|
+
params = params.slice(0, maxLen) + '…';
|
|
328
|
+
}
|
|
329
|
+
console.log(s.info(`● ${item.name}`) + s.muted(`(${params})`));
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
case 'tool-result': {
|
|
333
|
+
// Truncate long params (e.g., long bash commands)
|
|
334
|
+
const maxParamsLen = Math.min(60, terminal.getTerminalWidth() - 15);
|
|
335
|
+
let paramsDisplay = item.params;
|
|
336
|
+
// Handle multi-line params (show first line + count)
|
|
337
|
+
const newlineIdx = paramsDisplay.indexOf('\n');
|
|
338
|
+
if (newlineIdx !== -1) {
|
|
339
|
+
const firstLine = paramsDisplay.slice(0, newlineIdx).trim();
|
|
340
|
+
const lineCount = paramsDisplay.split('\n').length;
|
|
341
|
+
paramsDisplay = firstLine.length > 0
|
|
342
|
+
? `${firstLine}… (${String(lineCount)} lines)`
|
|
343
|
+
: `(${String(lineCount)} line script)`;
|
|
344
|
+
}
|
|
345
|
+
// Truncate if still too long
|
|
346
|
+
if (paramsDisplay.length > maxParamsLen) {
|
|
347
|
+
paramsDisplay = paramsDisplay.slice(0, maxParamsLen) + '…';
|
|
348
|
+
}
|
|
349
|
+
console.log(s.info(`● ${item.name}`) + s.muted(`(${paramsDisplay})`));
|
|
350
|
+
if (item.content) {
|
|
351
|
+
const lines = item.content.split('\n').filter((l) => l.length > 0);
|
|
352
|
+
const maxPreviewLines = 3;
|
|
353
|
+
if (this.config.verbose) {
|
|
354
|
+
// Verbose: show all lines
|
|
355
|
+
for (const line of lines) {
|
|
356
|
+
console.log(s.muted(` ⎿ ${line}`));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
else if (lines.length > 0) {
|
|
360
|
+
// Compact: show first few lines + hidden count
|
|
361
|
+
const previewLines = lines.slice(0, maxPreviewLines);
|
|
362
|
+
const hiddenCount = lines.length - maxPreviewLines;
|
|
363
|
+
for (let i = 0; i < previewLines.length; i++) {
|
|
364
|
+
if (i === 0) {
|
|
365
|
+
console.log(s.muted(` ⎿ ${previewLines[i]}`));
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
console.log(s.muted(` ${previewLines[i]}`));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (hiddenCount > 0) {
|
|
372
|
+
console.log(s.muted(` … +${String(hiddenCount)} lines`) + s.muted(' (ctrl+o to expand)'));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
// No output lines
|
|
377
|
+
const expandHint = s.muted(' (ctrl+o to expand)');
|
|
378
|
+
if (item.success === false) {
|
|
379
|
+
console.log(s.error(` ⎿ ${item.summary}`) + expandHint);
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
console.log(s.muted(` ⎿ ${item.summary}`) + expandHint);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
// No content, just show summary
|
|
388
|
+
if (item.success === false) {
|
|
389
|
+
console.log(s.error(` ⎿ ${item.summary}`));
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
console.log(s.muted(` ⎿ ${item.summary}`));
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
console.log('');
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
case 'tool-error': {
|
|
399
|
+
// Truncate long params
|
|
400
|
+
const maxErrLen = Math.min(60, terminal.getTerminalWidth() - 15);
|
|
401
|
+
let errParams = item.params;
|
|
402
|
+
const errNlIdx = errParams.indexOf('\n');
|
|
403
|
+
if (errNlIdx !== -1) {
|
|
404
|
+
const errFirst = errParams.slice(0, errNlIdx).trim();
|
|
405
|
+
const errCount = errParams.split('\n').length;
|
|
406
|
+
errParams = errFirst.length > 0 ? `${errFirst}… (${String(errCount)} lines)` : `(${String(errCount)} line script)`;
|
|
407
|
+
}
|
|
408
|
+
if (errParams.length > maxErrLen) {
|
|
409
|
+
errParams = errParams.slice(0, maxErrLen) + '…';
|
|
410
|
+
}
|
|
411
|
+
console.log(s.info(`● ${item.name}`) + s.muted(`(${errParams})`));
|
|
412
|
+
console.log(s.error(` ⎿ Error: ${item.error}`));
|
|
413
|
+
console.log('');
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
case 'interrupted': {
|
|
417
|
+
// Show what was ongoing (if provided)
|
|
418
|
+
if (item.action) {
|
|
419
|
+
console.log(s.info(`● ${item.action}`));
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
// No action means interrupted right after user message.
|
|
423
|
+
// Move cursor up to consume the trailing blank from user-message.
|
|
424
|
+
//
|
|
425
|
+
// ⚠️ WARNING: This cursor manipulation is fragile!
|
|
426
|
+
// It assumes the previous item was user-message which adds a trailing blank.
|
|
427
|
+
// If we add items that don't add trailing blanks, or if the rendering
|
|
428
|
+
// order changes, this could cause visual artifacts (overwriting content).
|
|
429
|
+
// If issues arise, consider tracking the last printed item type instead.
|
|
430
|
+
process.stdout.write('\x1b[1A'); // Move up one line
|
|
431
|
+
}
|
|
432
|
+
// Show interrupted line with mascot
|
|
433
|
+
const suggestion = item.suggestion ?? 'What should I do instead?';
|
|
434
|
+
console.log(s.warning(` ⎿ ${MASCOT.confused} Interrupted - ${suggestion}`));
|
|
435
|
+
console.log('');
|
|
436
|
+
// Also set as ghost text suggestion
|
|
437
|
+
this.setSuggestion(suggestion);
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
case 'error':
|
|
441
|
+
console.log(s.error(`${MASCOT.error} ${item.message}`));
|
|
442
|
+
console.log('');
|
|
443
|
+
break;
|
|
444
|
+
case 'success':
|
|
445
|
+
console.log(s.success(`${MASCOT.success} ${item.message}`));
|
|
446
|
+
console.log('');
|
|
447
|
+
break;
|
|
448
|
+
case 'info':
|
|
449
|
+
console.log(s.info(item.message));
|
|
450
|
+
console.log('');
|
|
451
|
+
break;
|
|
452
|
+
case 'warning':
|
|
453
|
+
console.log(s.warning(item.message));
|
|
454
|
+
console.log('');
|
|
455
|
+
break;
|
|
456
|
+
case 'raw':
|
|
457
|
+
console.log(item.text);
|
|
458
|
+
break;
|
|
459
|
+
case 'raw-lines':
|
|
460
|
+
for (const line of item.lines) {
|
|
461
|
+
console.log(line);
|
|
462
|
+
}
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Execute a callback that outputs to terminal.
|
|
468
|
+
* Handles clear → callback → render automatically.
|
|
469
|
+
* Use this for complex output that needs multiple console.log calls.
|
|
470
|
+
* @deprecated Prefer using print() with PrintableItem
|
|
471
|
+
*/
|
|
472
|
+
output(callback) {
|
|
473
|
+
this.clear();
|
|
474
|
+
callback();
|
|
475
|
+
this.render();
|
|
476
|
+
}
|
|
477
|
+
// ===========================================================================
|
|
478
|
+
// Public API - State setters
|
|
479
|
+
// ===========================================================================
|
|
480
|
+
setAgentRunning(running) {
|
|
481
|
+
const wasRunning = this.agentRunning;
|
|
482
|
+
this.agentRunning = running;
|
|
483
|
+
if (running && !wasRunning) {
|
|
484
|
+
this.startSpinnerAnimation();
|
|
485
|
+
// Let render loop handle it
|
|
486
|
+
}
|
|
487
|
+
else if (!running && wasRunning) {
|
|
488
|
+
this.stopSpinnerAnimation();
|
|
489
|
+
this.currentTool = null;
|
|
490
|
+
}
|
|
491
|
+
this.needsRender = true;
|
|
492
|
+
}
|
|
493
|
+
isAgentRunning() {
|
|
494
|
+
return this.agentRunning;
|
|
495
|
+
}
|
|
496
|
+
setTodos(todos) {
|
|
497
|
+
this.todos = todos;
|
|
498
|
+
this.needsRender = true;
|
|
499
|
+
}
|
|
500
|
+
getTodos() {
|
|
501
|
+
return [...this.todos];
|
|
502
|
+
}
|
|
503
|
+
setSpinnerText(text) {
|
|
504
|
+
this.spinnerText = text;
|
|
505
|
+
this.needsRender = true;
|
|
506
|
+
}
|
|
507
|
+
setCurrentTool(tool) {
|
|
508
|
+
this.currentTool = tool;
|
|
509
|
+
if (tool) {
|
|
510
|
+
this.spinnerText = tool;
|
|
511
|
+
}
|
|
512
|
+
this.needsRender = true;
|
|
513
|
+
}
|
|
514
|
+
setMode(mode) {
|
|
515
|
+
this.mode = mode;
|
|
516
|
+
this.needsRender = true;
|
|
517
|
+
}
|
|
518
|
+
getMode() {
|
|
519
|
+
return this.mode;
|
|
520
|
+
}
|
|
521
|
+
setProjectName(name) {
|
|
522
|
+
this.projectName = name;
|
|
523
|
+
this.needsRender = true;
|
|
524
|
+
}
|
|
525
|
+
getProjectName() {
|
|
526
|
+
return this.projectName;
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Set ghost text suggestion (shown when input is empty)
|
|
530
|
+
*/
|
|
531
|
+
setSuggestion(text) {
|
|
532
|
+
this.suggestion = text;
|
|
533
|
+
this.needsRender = true;
|
|
534
|
+
}
|
|
535
|
+
getSuggestion() {
|
|
536
|
+
return this.suggestion;
|
|
537
|
+
}
|
|
538
|
+
clearSuggestion() {
|
|
539
|
+
this.suggestion = null;
|
|
540
|
+
this.needsRender = true;
|
|
541
|
+
}
|
|
542
|
+
// ===========================================================================
|
|
543
|
+
// Public API - LiveRegion (subagents, bash commands)
|
|
544
|
+
// ===========================================================================
|
|
545
|
+
/**
|
|
546
|
+
* Add a subagent to the live region.
|
|
547
|
+
*/
|
|
548
|
+
addSubagent(id, agentType, description) {
|
|
549
|
+
const item = {
|
|
550
|
+
id,
|
|
551
|
+
type: 'subagent',
|
|
552
|
+
status: 'running',
|
|
553
|
+
startTime: Date.now(),
|
|
554
|
+
agentType,
|
|
555
|
+
description,
|
|
556
|
+
toolCount: 0,
|
|
557
|
+
tokenCount: 0,
|
|
558
|
+
lastAction: '',
|
|
559
|
+
lastActionDetails: [],
|
|
560
|
+
};
|
|
561
|
+
this.liveRegion.addItem(item);
|
|
562
|
+
// Let render loop handle it (same as addBashCommand)
|
|
563
|
+
this.needsRender = true;
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Update a subagent's tool usage.
|
|
567
|
+
*/
|
|
568
|
+
updateSubagentTool(id, toolName, summary) {
|
|
569
|
+
const item = this.liveRegion.getItem(id);
|
|
570
|
+
if (item && item.type === 'subagent') {
|
|
571
|
+
const newAction = `${toolName}: ${summary}`;
|
|
572
|
+
// Add previous action to history (for expanded view)
|
|
573
|
+
const updatedDetails = item.lastAction
|
|
574
|
+
? [...item.lastActionDetails, item.lastAction]
|
|
575
|
+
: [...item.lastActionDetails];
|
|
576
|
+
this.liveRegion.updateItem(id, {
|
|
577
|
+
toolCount: item.toolCount + 1,
|
|
578
|
+
lastAction: newAction,
|
|
579
|
+
lastActionDetails: updatedDetails,
|
|
580
|
+
});
|
|
581
|
+
this.needsRender = true;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Mark a subagent as completed.
|
|
586
|
+
*/
|
|
587
|
+
completeSubagent(id, success, tokenCount, _error) {
|
|
588
|
+
const item = this.liveRegion.getItem(id);
|
|
589
|
+
if (item && item.type === 'subagent') {
|
|
590
|
+
this.liveRegion.updateItem(id, {
|
|
591
|
+
status: success ? 'done' : 'error',
|
|
592
|
+
tokenCount: tokenCount ?? item.tokenCount,
|
|
593
|
+
endTime: Date.now(),
|
|
594
|
+
});
|
|
595
|
+
this.liveRegion.completeItem(id, success ? 'done' : 'error');
|
|
596
|
+
this.needsRender = true;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Remove a subagent and return it for committing to scrolling zone.
|
|
601
|
+
*/
|
|
602
|
+
removeSubagent(id) {
|
|
603
|
+
const item = this.liveRegion.removeItem(id);
|
|
604
|
+
this.needsRender = true;
|
|
605
|
+
return item;
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Clear all subagents from live region.
|
|
609
|
+
*/
|
|
610
|
+
clearLiveRegion() {
|
|
611
|
+
this.liveRegion.clear();
|
|
612
|
+
this.needsRender = true;
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Toggle expanded view in live region (Ctrl+O).
|
|
616
|
+
*/
|
|
617
|
+
toggleLiveRegionExpanded() {
|
|
618
|
+
this.liveRegion.toggleExpanded();
|
|
619
|
+
this.needsRender = true;
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Check if live region has items.
|
|
623
|
+
*/
|
|
624
|
+
hasLiveItems() {
|
|
625
|
+
return this.liveRegion.hasItems();
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Get live region for direct access if needed.
|
|
629
|
+
*/
|
|
630
|
+
getLiveRegion() {
|
|
631
|
+
return this.liveRegion;
|
|
632
|
+
}
|
|
633
|
+
// ===========================================================================
|
|
634
|
+
// Live Region - Bash Commands
|
|
635
|
+
// ===========================================================================
|
|
636
|
+
/**
|
|
637
|
+
* Add a bash command to the live region.
|
|
638
|
+
*/
|
|
639
|
+
addBashCommand(id, command) {
|
|
640
|
+
const item = {
|
|
641
|
+
id,
|
|
642
|
+
type: 'bash',
|
|
643
|
+
status: 'running',
|
|
644
|
+
startTime: Date.now(),
|
|
645
|
+
command,
|
|
646
|
+
output: [],
|
|
647
|
+
};
|
|
648
|
+
this.liveRegion.addItem(item);
|
|
649
|
+
this.needsRender = true;
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Add output line to a bash command.
|
|
653
|
+
*/
|
|
654
|
+
updateBashOutput(id, line) {
|
|
655
|
+
const item = this.liveRegion.getItem(id);
|
|
656
|
+
if (item && item.type === 'bash') {
|
|
657
|
+
item.output.push(line);
|
|
658
|
+
this.liveRegion.updateItem(id, { output: [...item.output] });
|
|
659
|
+
this.needsRender = true;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Mark a bash command as completed.
|
|
664
|
+
*/
|
|
665
|
+
completeBashCommand(id, exitCode) {
|
|
666
|
+
const item = this.liveRegion.getItem(id);
|
|
667
|
+
if (item && item.type === 'bash') {
|
|
668
|
+
this.liveRegion.updateItem(id, {
|
|
669
|
+
status: exitCode === 0 ? 'done' : 'error',
|
|
670
|
+
exitCode,
|
|
671
|
+
endTime: Date.now(),
|
|
672
|
+
});
|
|
673
|
+
this.liveRegion.completeItem(id, exitCode === 0 ? 'done' : 'error');
|
|
674
|
+
this.needsRender = true;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Remove a bash command and commit it to the scrolling zone.
|
|
679
|
+
*/
|
|
680
|
+
commitBashCommand(id) {
|
|
681
|
+
const item = this.liveRegion.removeItem(id);
|
|
682
|
+
if (item && item.type === 'bash') {
|
|
683
|
+
const output = item.output.join('\n') || '(no output)';
|
|
684
|
+
const exitCode = item.exitCode ?? 0;
|
|
685
|
+
// Print the bash output to scrolling zone
|
|
686
|
+
this.print({
|
|
687
|
+
type: 'tool-result',
|
|
688
|
+
name: 'Bash',
|
|
689
|
+
params: item.command,
|
|
690
|
+
summary: exitCode === 0 ? 'Completed' : `Exit code ${String(exitCode)}`,
|
|
691
|
+
content: output,
|
|
692
|
+
success: exitCode === 0,
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
this.needsRender = true;
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Remove a subagent and commit it to the scrolling zone.
|
|
699
|
+
* This is the atomic equivalent of commitBashCommand for subagents.
|
|
700
|
+
* The result content comes from tool_end, not from the LiveRegion item.
|
|
701
|
+
*/
|
|
702
|
+
commitSubagent(id, resultContent, success) {
|
|
703
|
+
// Remove from LiveRegion FIRST (no needsRender yet - that's the key!)
|
|
704
|
+
const item = this.liveRegion.removeItem(id);
|
|
705
|
+
if (item && item.type === 'subagent') {
|
|
706
|
+
// Build summary from item stats
|
|
707
|
+
const toolCount = item.toolCount;
|
|
708
|
+
const tokenCount = item.tokenCount;
|
|
709
|
+
const duration = item.endTime
|
|
710
|
+
? Math.round((item.endTime - item.startTime) / 1000)
|
|
711
|
+
: Math.round((Date.now() - item.startTime) / 1000);
|
|
712
|
+
const tokenStr = tokenCount > 0 ? `, ${this.formatTokens(tokenCount)} tokens` : '';
|
|
713
|
+
const summary = `${String(toolCount)} tool calls${tokenStr}, ${String(duration)}s`;
|
|
714
|
+
// Print atomically (uses current lastRenderHeight before it changes)
|
|
715
|
+
this.print({
|
|
716
|
+
type: 'tool-result',
|
|
717
|
+
name: item.agentType,
|
|
718
|
+
params: item.description,
|
|
719
|
+
summary,
|
|
720
|
+
content: resultContent.length > 200 ? resultContent : undefined,
|
|
721
|
+
success,
|
|
722
|
+
});
|
|
723
|
+
// Reset spinner text
|
|
724
|
+
this.spinnerText = null;
|
|
725
|
+
}
|
|
726
|
+
// Set needsRender AFTER print (same pattern as commitBashCommand)
|
|
727
|
+
this.needsRender = true;
|
|
728
|
+
return item;
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Format token count (e.g., 5000 -> "5.0k")
|
|
732
|
+
*/
|
|
733
|
+
formatTokens(tokens) {
|
|
734
|
+
if (tokens >= 1000) {
|
|
735
|
+
return `${(tokens / 1000).toFixed(1)}k`;
|
|
736
|
+
}
|
|
737
|
+
return String(tokens);
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Toggle todo list visibility (Ctrl+T)
|
|
741
|
+
*/
|
|
742
|
+
toggleTodos() {
|
|
743
|
+
this.showTodos = !this.showTodos;
|
|
744
|
+
this.needsRender = true;
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Set todo list visibility
|
|
748
|
+
*/
|
|
749
|
+
setShowTodos(show) {
|
|
750
|
+
this.showTodos = show;
|
|
751
|
+
this.needsRender = true;
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Get todo list visibility
|
|
755
|
+
*/
|
|
756
|
+
getShowTodos() {
|
|
757
|
+
return this.showTodos;
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* Toggle verbose view mode (Ctrl+O)
|
|
761
|
+
* Shows conversation in verbose mode temporarily.
|
|
762
|
+
* Any key returns to normal view.
|
|
763
|
+
*/
|
|
764
|
+
toggleVerboseView() {
|
|
765
|
+
if (this.viewMode === 'normal') {
|
|
766
|
+
this.viewMode = 'verbose-temp';
|
|
767
|
+
this.reRenderConversationVerbose(true);
|
|
768
|
+
}
|
|
769
|
+
else {
|
|
770
|
+
this.viewMode = 'normal';
|
|
771
|
+
this.reRenderConversationVerbose(false);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Re-render conversation with temporary verbose setting.
|
|
776
|
+
*/
|
|
777
|
+
reRenderConversationVerbose(verbose) {
|
|
778
|
+
const s = getStyles();
|
|
779
|
+
// Clear screen and scrollback buffer
|
|
780
|
+
process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
|
|
781
|
+
// Reset footer state
|
|
782
|
+
this.lastRenderHeight = 0;
|
|
783
|
+
this.cursorLineFromBottom = 0;
|
|
784
|
+
// Show mode indicator at top
|
|
785
|
+
if (verbose) {
|
|
786
|
+
console.log(s.info(`[Verbose View Mode] Press any key to return to normal view`));
|
|
787
|
+
console.log('');
|
|
788
|
+
}
|
|
789
|
+
// Re-render all items with temporary verbose override
|
|
790
|
+
const originalVerbose = this.config.verbose;
|
|
791
|
+
this.config.verbose = verbose;
|
|
792
|
+
for (const item of this.conversationHistory) {
|
|
793
|
+
this.renderItem(item);
|
|
794
|
+
}
|
|
795
|
+
// Restore original config
|
|
796
|
+
this.config.verbose = originalVerbose;
|
|
797
|
+
// Footer will be re-rendered by the render loop
|
|
798
|
+
this.needsRender = true;
|
|
799
|
+
}
|
|
800
|
+
// ===========================================================================
|
|
801
|
+
// Public API - Config
|
|
802
|
+
// ===========================================================================
|
|
803
|
+
/**
|
|
804
|
+
* Update configuration at runtime.
|
|
805
|
+
* If verbose changes, re-renders the entire conversation.
|
|
806
|
+
*/
|
|
807
|
+
setConfig(newConfig) {
|
|
808
|
+
const oldVerbose = this.config.verbose;
|
|
809
|
+
this.config = { ...this.config, ...newConfig };
|
|
810
|
+
// If verbose changed, re-render entire conversation
|
|
811
|
+
if (newConfig.verbose !== undefined && newConfig.verbose !== oldVerbose) {
|
|
812
|
+
this.reRenderConversation();
|
|
813
|
+
}
|
|
814
|
+
// Theme change requires prompt refresh
|
|
815
|
+
if (newConfig.theme !== undefined) {
|
|
816
|
+
this.refreshPrompt();
|
|
817
|
+
}
|
|
818
|
+
this.needsRender = true;
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Get current configuration.
|
|
822
|
+
*/
|
|
823
|
+
getConfig() {
|
|
824
|
+
return { ...this.config };
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* Re-render entire conversation with current config.
|
|
828
|
+
* Called when verbose mode changes.
|
|
829
|
+
*/
|
|
830
|
+
reRenderConversation() {
|
|
831
|
+
// Clear screen AND scrollback buffer, then move to home
|
|
832
|
+
// \x1b[2J - clear entire screen
|
|
833
|
+
// \x1b[3J - clear scrollback buffer (prevents accumulation)
|
|
834
|
+
// \x1b[H - move cursor to home position
|
|
835
|
+
process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
|
|
836
|
+
// Reset footer state since we cleared everything
|
|
837
|
+
this.lastRenderHeight = 0;
|
|
838
|
+
this.cursorLineFromBottom = 0;
|
|
839
|
+
// Re-render all items with new config
|
|
840
|
+
for (const item of this.conversationHistory) {
|
|
841
|
+
this.renderItem(item);
|
|
842
|
+
}
|
|
843
|
+
// Footer will be re-rendered by the render loop
|
|
844
|
+
this.needsRender = true;
|
|
845
|
+
}
|
|
846
|
+
// ===========================================================================
|
|
847
|
+
// Public API - Conversation History
|
|
848
|
+
// ===========================================================================
|
|
849
|
+
/**
|
|
850
|
+
* Clear conversation history.
|
|
851
|
+
* Called by /clear command.
|
|
852
|
+
*/
|
|
853
|
+
clearConversationHistory() {
|
|
854
|
+
this.conversationHistory = [];
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Get conversation history (copy).
|
|
858
|
+
*/
|
|
859
|
+
getConversationHistory() {
|
|
860
|
+
return [...this.conversationHistory];
|
|
861
|
+
}
|
|
862
|
+
// ===========================================================================
|
|
863
|
+
// Public API - Overlays
|
|
864
|
+
// ===========================================================================
|
|
865
|
+
/**
|
|
866
|
+
* Check if an overlay is currently active.
|
|
867
|
+
*/
|
|
868
|
+
hasActiveOverlay() {
|
|
869
|
+
return this.overlayStack.length > 0;
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Get the currently active overlay (if any).
|
|
873
|
+
*/
|
|
874
|
+
getActiveOverlay() {
|
|
875
|
+
return this.overlayStack.length > 0
|
|
876
|
+
? this.overlayStack[this.overlayStack.length - 1]
|
|
877
|
+
: null;
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Show an overlay and wait for result.
|
|
881
|
+
* Returns promise that resolves when overlay closes.
|
|
882
|
+
*/
|
|
883
|
+
async showOverlay(overlay) {
|
|
884
|
+
// Check if we're pushing onto an existing overlay (stack has items before this push)
|
|
885
|
+
const isPushingOntoExisting = this.overlayStack.length > 0;
|
|
886
|
+
// 1. If pushing onto existing overlay, clear its render first
|
|
887
|
+
if (isPushingOntoExisting) {
|
|
888
|
+
this.clearOverlayRender();
|
|
889
|
+
}
|
|
890
|
+
// 2. Push overlay onto stack
|
|
891
|
+
this.overlayStack.push(overlay);
|
|
892
|
+
// 3. Call onMount lifecycle (alternate screen overlays enter alternate screen here)
|
|
893
|
+
await overlay.onMount?.();
|
|
894
|
+
// 4. Switch render mode based on overlay type
|
|
895
|
+
// Skip for alternate screen overlays - they manage their own screen buffer
|
|
896
|
+
if (!overlay.usesAlternateScreen) {
|
|
897
|
+
if (overlay.type === 'fullscreen') {
|
|
898
|
+
this.enterFullscreenOverlayMode();
|
|
899
|
+
}
|
|
900
|
+
else {
|
|
901
|
+
this.enterInlineOverlayMode();
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
// 5. Initial render
|
|
905
|
+
this.renderOverlay();
|
|
906
|
+
// 6. Wait for close
|
|
907
|
+
return new Promise((resolve) => {
|
|
908
|
+
this.overlayResolvers.set(overlay.id, resolve);
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Close current overlay with result.
|
|
913
|
+
* Called internally when overlay returns close action.
|
|
914
|
+
*/
|
|
915
|
+
closeCurrentOverlay(result, cancelled) {
|
|
916
|
+
const overlay = this.overlayStack.pop();
|
|
917
|
+
if (!overlay)
|
|
918
|
+
return;
|
|
919
|
+
// Check if overlay uses alternate screen (manages its own screen buffer)
|
|
920
|
+
const usesAlternateScreen = overlay.usesAlternateScreen === true;
|
|
921
|
+
// 1. Call onUnmount lifecycle (this exits alternate screen if used)
|
|
922
|
+
overlay.onUnmount?.();
|
|
923
|
+
// 2. Get close summary before clearing (needs overlay reference)
|
|
924
|
+
const summary = (!cancelled && result !== null)
|
|
925
|
+
? overlay.getCloseSummary?.(result)
|
|
926
|
+
: null;
|
|
927
|
+
// 3. Restore render mode FIRST (clears overlay from screen)
|
|
928
|
+
if (this.overlayStack.length === 0) {
|
|
929
|
+
// Skip exitOverlayMode for alternate screen overlays - exiting alternate
|
|
930
|
+
// screen already restored the main screen, clearing would corrupt it
|
|
931
|
+
if (usesAlternateScreen) {
|
|
932
|
+
// Just reset state, skip clearing AND skip re-rendering footer
|
|
933
|
+
// (alternate screen restore already put the screen back to its previous state)
|
|
934
|
+
this.overlayRenderState = { lineCount: 0, maxLineCount: 0 };
|
|
935
|
+
this.flushOverlayPrintBuffer();
|
|
936
|
+
// Don't call render() - the footer was restored by alternate screen exit
|
|
937
|
+
}
|
|
938
|
+
else {
|
|
939
|
+
this.exitOverlayMode();
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
else {
|
|
943
|
+
// Popping back to parent overlay:
|
|
944
|
+
// 1. Clear the child overlay's render (skip for alternate screen)
|
|
945
|
+
if (!usesAlternateScreen) {
|
|
946
|
+
this.clearOverlayRender();
|
|
947
|
+
}
|
|
948
|
+
// 2. Reset render state for parent
|
|
949
|
+
this.overlayRenderState = { lineCount: 0, maxLineCount: 0 };
|
|
950
|
+
// 3. Re-render parent overlay
|
|
951
|
+
this.renderOverlay();
|
|
952
|
+
}
|
|
953
|
+
// 4. Show close summary AFTER clearing overlay
|
|
954
|
+
if (summary) {
|
|
955
|
+
this.print({ type: 'info', message: summary });
|
|
956
|
+
}
|
|
957
|
+
// 5. Resolve promise
|
|
958
|
+
const resolver = this.overlayResolvers.get(overlay.id);
|
|
959
|
+
resolver?.(result);
|
|
960
|
+
this.overlayResolvers.delete(overlay.id);
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Enter fullscreen overlay mode.
|
|
964
|
+
* Clears screen and prepares for overlay rendering.
|
|
965
|
+
*/
|
|
966
|
+
enterFullscreenOverlayMode() {
|
|
967
|
+
// Clear footer first
|
|
968
|
+
this.clear();
|
|
969
|
+
// Clear screen for fullscreen overlay
|
|
970
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
971
|
+
// Reset overlay render state (both lineCount and maxLineCount)
|
|
972
|
+
this.overlayRenderState = { lineCount: 0, maxLineCount: 0 };
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Enter inline overlay mode.
|
|
976
|
+
* Clears footer but keeps conversation visible.
|
|
977
|
+
*/
|
|
978
|
+
enterInlineOverlayMode() {
|
|
979
|
+
// Clear footer to make room for inline overlay
|
|
980
|
+
this.clear();
|
|
981
|
+
// Reset overlay render state (both lineCount and maxLineCount)
|
|
982
|
+
this.overlayRenderState = { lineCount: 0, maxLineCount: 0 };
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Exit overlay mode and restore normal rendering.
|
|
986
|
+
*/
|
|
987
|
+
exitOverlayMode() {
|
|
988
|
+
// Clear overlay content (uses maxLineCount to ensure all lines are cleared)
|
|
989
|
+
this.clearOverlayRender();
|
|
990
|
+
// Reset state
|
|
991
|
+
this.overlayRenderState = { lineCount: 0, maxLineCount: 0 };
|
|
992
|
+
// Flush any items that were buffered while overlay was active
|
|
993
|
+
this.flushOverlayPrintBuffer();
|
|
994
|
+
// Re-render footer
|
|
995
|
+
this.render();
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* Flush items that were buffered while an overlay was active.
|
|
999
|
+
* Called after overlay closes to render any pending output.
|
|
1000
|
+
*/
|
|
1001
|
+
flushOverlayPrintBuffer() {
|
|
1002
|
+
if (this.overlayPrintBuffer.length === 0)
|
|
1003
|
+
return;
|
|
1004
|
+
// Render all buffered items
|
|
1005
|
+
for (const item of this.overlayPrintBuffer) {
|
|
1006
|
+
this.renderItem(item);
|
|
1007
|
+
}
|
|
1008
|
+
// Clear the buffer
|
|
1009
|
+
this.overlayPrintBuffer = [];
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Clear previous overlay render.
|
|
1013
|
+
* Uses maxLineCount to ensure all lines are cleared even if content shrank between renders.
|
|
1014
|
+
*/
|
|
1015
|
+
clearOverlayRender() {
|
|
1016
|
+
// Use maxLineCount to handle cases where overlay content varied between renders
|
|
1017
|
+
const linesToClear = this.overlayRenderState.maxLineCount;
|
|
1018
|
+
if (linesToClear > 0) {
|
|
1019
|
+
// Move cursor up to start of overlay
|
|
1020
|
+
process.stdout.write(`\x1b[${String(linesToClear)}A`);
|
|
1021
|
+
// Move to column 1
|
|
1022
|
+
process.stdout.write('\r');
|
|
1023
|
+
// Clear from cursor to end of screen
|
|
1024
|
+
process.stdout.write('\x1b[J');
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Render the active overlay.
|
|
1029
|
+
*/
|
|
1030
|
+
renderOverlay() {
|
|
1031
|
+
const overlay = this.getActiveOverlay();
|
|
1032
|
+
if (!overlay)
|
|
1033
|
+
return;
|
|
1034
|
+
const termWidth = terminal.getTerminalWidth();
|
|
1035
|
+
const termHeight = terminal.getTerminalHeight();
|
|
1036
|
+
const s = getStyles();
|
|
1037
|
+
const context = {
|
|
1038
|
+
width: termWidth,
|
|
1039
|
+
height: termHeight,
|
|
1040
|
+
styles: s,
|
|
1041
|
+
};
|
|
1042
|
+
const content = overlay.render(context);
|
|
1043
|
+
if (overlay.type === 'fullscreen') {
|
|
1044
|
+
this.renderFullscreenOverlay(content, termWidth);
|
|
1045
|
+
}
|
|
1046
|
+
else {
|
|
1047
|
+
this.renderInlineOverlay(content, termWidth);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* Render fullscreen overlay.
|
|
1052
|
+
*
|
|
1053
|
+
* CRITICAL: Cursor positioning must be consistent between renders.
|
|
1054
|
+
* Same logic as renderInlineOverlay - pad to maxLineCount for consistency.
|
|
1055
|
+
*/
|
|
1056
|
+
renderFullscreenOverlay(content, termWidth) {
|
|
1057
|
+
// Clear previous render
|
|
1058
|
+
this.clearOverlayRender();
|
|
1059
|
+
// Calculate physical lines
|
|
1060
|
+
let physicalLines = 0;
|
|
1061
|
+
for (const line of content.lines) {
|
|
1062
|
+
const visibleLen = getVisibleLength(line);
|
|
1063
|
+
physicalLines += Math.max(1, Math.ceil(visibleLen / termWidth));
|
|
1064
|
+
}
|
|
1065
|
+
// Update maxLineCount BEFORE padding (track the natural maximum)
|
|
1066
|
+
const contentMinHeight = content.minHeight ?? 0;
|
|
1067
|
+
const naturalHeight = Math.max(physicalLines, contentMinHeight);
|
|
1068
|
+
this.overlayRenderState.maxLineCount = Math.max(this.overlayRenderState.maxLineCount, naturalHeight);
|
|
1069
|
+
// CRITICAL: Pad to maxLineCount for consistent cursor positioning
|
|
1070
|
+
const targetHeight = this.overlayRenderState.maxLineCount;
|
|
1071
|
+
const paddedLines = [...content.lines];
|
|
1072
|
+
while (physicalLines < targetHeight) {
|
|
1073
|
+
paddedLines.push('');
|
|
1074
|
+
physicalLines++;
|
|
1075
|
+
}
|
|
1076
|
+
// Update lineCount to actual rendered height
|
|
1077
|
+
this.overlayRenderState.lineCount = physicalLines;
|
|
1078
|
+
// Render lines
|
|
1079
|
+
for (let i = 0; i < paddedLines.length; i++) {
|
|
1080
|
+
process.stdout.write(paddedLines[i]);
|
|
1081
|
+
if (i < paddedLines.length - 1) {
|
|
1082
|
+
process.stdout.write('\n');
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
// Position cursor if specified
|
|
1086
|
+
if (content.cursorPosition) {
|
|
1087
|
+
const { line, column } = content.cursorPosition;
|
|
1088
|
+
// Move to the correct line (relative from end)
|
|
1089
|
+
const linesFromEnd = paddedLines.length - 1 - line;
|
|
1090
|
+
if (linesFromEnd > 0) {
|
|
1091
|
+
process.stdout.write(`\x1b[${String(linesFromEnd)}A`);
|
|
1092
|
+
}
|
|
1093
|
+
process.stdout.write(`\x1b[${String(column)}G`);
|
|
1094
|
+
}
|
|
1095
|
+
terminal.showCursor();
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Render inline overlay (above footer).
|
|
1099
|
+
*
|
|
1100
|
+
* CRITICAL: Cursor positioning must be consistent between renders.
|
|
1101
|
+
* After rendering, cursor MUST be at maxLineCount position, not at
|
|
1102
|
+
* actual content height. Otherwise, the next clear will move cursor
|
|
1103
|
+
* up by maxLineCount from the wrong position, causing "ghost lines".
|
|
1104
|
+
*/
|
|
1105
|
+
renderInlineOverlay(content, termWidth) {
|
|
1106
|
+
// Clear previous render
|
|
1107
|
+
this.clearOverlayRender();
|
|
1108
|
+
// Calculate physical lines
|
|
1109
|
+
let physicalLines = 0;
|
|
1110
|
+
for (const line of content.lines) {
|
|
1111
|
+
const visibleLen = getVisibleLength(line);
|
|
1112
|
+
physicalLines += Math.max(1, Math.ceil(visibleLen / termWidth));
|
|
1113
|
+
}
|
|
1114
|
+
// Update maxLineCount BEFORE padding (track the natural maximum)
|
|
1115
|
+
const contentMinHeight = content.minHeight ?? 0;
|
|
1116
|
+
const naturalHeight = Math.max(physicalLines, contentMinHeight);
|
|
1117
|
+
this.overlayRenderState.maxLineCount = Math.max(this.overlayRenderState.maxLineCount, naturalHeight);
|
|
1118
|
+
// CRITICAL: Pad to maxLineCount, not just minHeight!
|
|
1119
|
+
// This ensures cursor position is consistent between renders.
|
|
1120
|
+
// Without this, going from tall content (e.g., detail screen with 25 lines)
|
|
1121
|
+
// to short content (e.g., main list with 20 lines) leaves cursor at line 20,
|
|
1122
|
+
// but next clear moves up 25 lines → cursor goes to line -5 → ghost lines!
|
|
1123
|
+
const targetHeight = this.overlayRenderState.maxLineCount;
|
|
1124
|
+
const paddedLines = [...content.lines];
|
|
1125
|
+
while (physicalLines < targetHeight) {
|
|
1126
|
+
paddedLines.push('');
|
|
1127
|
+
physicalLines++;
|
|
1128
|
+
}
|
|
1129
|
+
// Update lineCount to actual rendered height
|
|
1130
|
+
this.overlayRenderState.lineCount = physicalLines;
|
|
1131
|
+
// Render lines (each line ends with \n for consistent cursor positioning)
|
|
1132
|
+
for (const line of paddedLines) {
|
|
1133
|
+
process.stdout.write(line + '\n');
|
|
1134
|
+
}
|
|
1135
|
+
terminal.showCursor();
|
|
1136
|
+
}
|
|
1137
|
+
/**
|
|
1138
|
+
* Process overlay action returned by handleKey.
|
|
1139
|
+
*/
|
|
1140
|
+
processOverlayAction(action) {
|
|
1141
|
+
switch (action.type) {
|
|
1142
|
+
case 'none':
|
|
1143
|
+
// Do nothing
|
|
1144
|
+
break;
|
|
1145
|
+
case 'render':
|
|
1146
|
+
this.renderOverlay();
|
|
1147
|
+
break;
|
|
1148
|
+
case 'close':
|
|
1149
|
+
if (action.cancelled) {
|
|
1150
|
+
this.closeCurrentOverlay(null, true);
|
|
1151
|
+
}
|
|
1152
|
+
else {
|
|
1153
|
+
this.closeCurrentOverlay(action.result ?? null, false);
|
|
1154
|
+
}
|
|
1155
|
+
break;
|
|
1156
|
+
case 'push':
|
|
1157
|
+
if (action.overlay) {
|
|
1158
|
+
void this.showOverlay(action.overlay);
|
|
1159
|
+
}
|
|
1160
|
+
break;
|
|
1161
|
+
case 'pop':
|
|
1162
|
+
this.closeCurrentOverlay(null, true);
|
|
1163
|
+
break;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
// ===========================================================================
|
|
1167
|
+
// Public API - Input queue
|
|
1168
|
+
// ===========================================================================
|
|
1169
|
+
getQueuedInputs() {
|
|
1170
|
+
return [...this.queuedInputs];
|
|
1171
|
+
}
|
|
1172
|
+
popQueuedInput() {
|
|
1173
|
+
if (this.queuedInputs.length === 0)
|
|
1174
|
+
return null;
|
|
1175
|
+
const input = this.queuedInputs.shift();
|
|
1176
|
+
this.needsRender = true;
|
|
1177
|
+
return input ?? null;
|
|
1178
|
+
}
|
|
1179
|
+
hasQueuedInput() {
|
|
1180
|
+
return this.queuedInputs.length > 0;
|
|
1181
|
+
}
|
|
1182
|
+
clearQueue() {
|
|
1183
|
+
this.queuedInputs = [];
|
|
1184
|
+
this.needsRender = true;
|
|
1185
|
+
}
|
|
1186
|
+
// ===========================================================================
|
|
1187
|
+
// Public API - Agent message queue
|
|
1188
|
+
// ===========================================================================
|
|
1189
|
+
/**
|
|
1190
|
+
* Queue a message to be sent to the agent.
|
|
1191
|
+
* Used by commands that need to invoke the agent programmatically.
|
|
1192
|
+
*/
|
|
1193
|
+
queueAgentMessage(options) {
|
|
1194
|
+
this.agentMessageQueue.push(options);
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Pop the next agent message from the queue.
|
|
1198
|
+
*/
|
|
1199
|
+
popAgentMessage() {
|
|
1200
|
+
return this.agentMessageQueue.shift() ?? null;
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* Check if there are queued agent messages.
|
|
1204
|
+
*/
|
|
1205
|
+
hasAgentMessage() {
|
|
1206
|
+
return this.agentMessageQueue.length > 0;
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Clear all queued agent messages.
|
|
1210
|
+
*/
|
|
1211
|
+
clearAgentMessageQueue() {
|
|
1212
|
+
this.agentMessageQueue = [];
|
|
1213
|
+
}
|
|
1214
|
+
// ===========================================================================
|
|
1215
|
+
// Public API - Animation control (for overlays)
|
|
1216
|
+
// ===========================================================================
|
|
1217
|
+
/**
|
|
1218
|
+
* Pause footer completely (for overlays)
|
|
1219
|
+
*/
|
|
1220
|
+
pauseAnimation() {
|
|
1221
|
+
this.isPaused = true;
|
|
1222
|
+
// Stop render loop
|
|
1223
|
+
if (this.renderTimer) {
|
|
1224
|
+
clearInterval(this.renderTimer);
|
|
1225
|
+
this.renderTimer = null;
|
|
1226
|
+
}
|
|
1227
|
+
// Stop spinner
|
|
1228
|
+
this.stopSpinnerAnimation();
|
|
1229
|
+
// Stop keyboard capture
|
|
1230
|
+
this.stopKeyboardCapture();
|
|
1231
|
+
// Clear footer from screen
|
|
1232
|
+
this.clear();
|
|
1233
|
+
}
|
|
1234
|
+
/**
|
|
1235
|
+
* Resume footer after pause
|
|
1236
|
+
*/
|
|
1237
|
+
resumeAnimation() {
|
|
1238
|
+
this.isPaused = false;
|
|
1239
|
+
// Resume spinner if agent running
|
|
1240
|
+
if (this.agentRunning) {
|
|
1241
|
+
this.startSpinnerAnimation();
|
|
1242
|
+
}
|
|
1243
|
+
// Restart keyboard capture
|
|
1244
|
+
this.startKeyboardCapture();
|
|
1245
|
+
// Restart render loop
|
|
1246
|
+
this.render();
|
|
1247
|
+
this.renderTimer = setInterval(() => {
|
|
1248
|
+
if (this.needsRender && !this.isPaused) {
|
|
1249
|
+
this.render();
|
|
1250
|
+
this.needsRender = false;
|
|
1251
|
+
}
|
|
1252
|
+
}, 60);
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* Restart input after command
|
|
1256
|
+
*/
|
|
1257
|
+
restartInput() {
|
|
1258
|
+
this.render();
|
|
1259
|
+
}
|
|
1260
|
+
/**
|
|
1261
|
+
* Refresh prompt with theme colors
|
|
1262
|
+
*/
|
|
1263
|
+
refreshPrompt() {
|
|
1264
|
+
const s = getStyles();
|
|
1265
|
+
this.promptPrefix = s.primaryBold('compilr>') + ' ';
|
|
1266
|
+
this.promptPrefixLen = getVisibleLength(this.promptPrefix);
|
|
1267
|
+
this.needsRender = true;
|
|
1268
|
+
}
|
|
1269
|
+
// ===========================================================================
|
|
1270
|
+
// Public API - Legacy compatibility (for gradual migration)
|
|
1271
|
+
// ===========================================================================
|
|
1272
|
+
/**
|
|
1273
|
+
* @deprecated Use print() or output() instead
|
|
1274
|
+
*/
|
|
1275
|
+
clearForOutput() {
|
|
1276
|
+
this.clear();
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* @deprecated Use print() or output() instead
|
|
1280
|
+
*/
|
|
1281
|
+
forceRender() {
|
|
1282
|
+
this.render();
|
|
1283
|
+
this.needsRender = false;
|
|
1284
|
+
}
|
|
1285
|
+
// ===========================================================================
|
|
1286
|
+
// Private - Rendering
|
|
1287
|
+
// ===========================================================================
|
|
1288
|
+
clear() {
|
|
1289
|
+
if (this.lastRenderHeight === 0) {
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
terminal.moveCursorToLineStart();
|
|
1293
|
+
// Move cursor to TOP of footer
|
|
1294
|
+
// cursorRowFromTop = which row the cursor is on (1-indexed from top of footer)
|
|
1295
|
+
const cursorRowFromTop = this.lastRenderHeight - this.cursorLineFromBottom;
|
|
1296
|
+
const rowsToMoveUp = cursorRowFromTop - 1;
|
|
1297
|
+
if (rowsToMoveUp > 0) {
|
|
1298
|
+
terminal.moveCursorUp(rowsToMoveUp);
|
|
1299
|
+
}
|
|
1300
|
+
terminal.clearToEndOfScreen();
|
|
1301
|
+
this.lastRenderHeight = 0;
|
|
1302
|
+
this.cursorLineFromBottom = 0;
|
|
1303
|
+
}
|
|
1304
|
+
render() {
|
|
1305
|
+
if (!this.isRunning || this.isPaused) {
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
// Don't render footer when overlay is active
|
|
1309
|
+
if (this.hasActiveOverlay()) {
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
const termWidth = terminal.getTerminalWidth();
|
|
1313
|
+
const s = getStyles();
|
|
1314
|
+
// Build all lines in order
|
|
1315
|
+
const lines = [];
|
|
1316
|
+
// 0. LiveRegion (if has items) - rendered at top of footer
|
|
1317
|
+
if (this.liveRegion.hasItems()) {
|
|
1318
|
+
const liveLines = this.liveRegion.render({
|
|
1319
|
+
width: termWidth,
|
|
1320
|
+
verbose: this.config.verbose,
|
|
1321
|
+
showTokens: true,
|
|
1322
|
+
});
|
|
1323
|
+
for (const line of liveLines) {
|
|
1324
|
+
lines.push({
|
|
1325
|
+
content: line,
|
|
1326
|
+
physicalLines: getPhysicalLineCount(line, termWidth),
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
// Blank line after live region for spacing
|
|
1330
|
+
if (liveLines.length > 0) {
|
|
1331
|
+
lines.push({ content: '', physicalLines: 1 });
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
// 1. Header: Spinner (when running) or "Todos" (when idle with todos)
|
|
1335
|
+
if (this.agentRunning) {
|
|
1336
|
+
const spinnerLine = this.buildSpinnerLine();
|
|
1337
|
+
lines.push({
|
|
1338
|
+
content: spinnerLine,
|
|
1339
|
+
physicalLines: getPhysicalLineCount(spinnerLine, termWidth),
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
else if (this.todos.length > 0) {
|
|
1343
|
+
const headerLine = s.muted('Todos');
|
|
1344
|
+
lines.push({
|
|
1345
|
+
content: headerLine,
|
|
1346
|
+
physicalLines: 1,
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
// 2. Todo list (show if there are todos AND showTodos is true)
|
|
1350
|
+
if (this.todos.length > 0 && this.showTodos) {
|
|
1351
|
+
for (const todo of this.todos) {
|
|
1352
|
+
const todoLine = this.buildTodoLine(todo);
|
|
1353
|
+
lines.push({
|
|
1354
|
+
content: todoLine,
|
|
1355
|
+
physicalLines: getPhysicalLineCount(todoLine, termWidth),
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
// Blank line after todos for spacing
|
|
1359
|
+
lines.push({ content: '', physicalLines: 1 });
|
|
1360
|
+
}
|
|
1361
|
+
// 3. Queued inputs (show multiline as single line with ↵ indicator)
|
|
1362
|
+
for (const queued of this.queuedInputs) {
|
|
1363
|
+
const displayText = queued.replace(/\n/g, ' ↵ ');
|
|
1364
|
+
const queuedLine = s.muted(`queued: "${displayText}"`);
|
|
1365
|
+
lines.push({
|
|
1366
|
+
content: queuedLine,
|
|
1367
|
+
physicalLines: getPhysicalLineCount(queuedLine, termWidth),
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
// 4. Top separator
|
|
1371
|
+
const separator = '─'.repeat(termWidth);
|
|
1372
|
+
lines.push({ content: separator, physicalLines: 1 });
|
|
1373
|
+
// 5. Input prompt (multiline support)
|
|
1374
|
+
const continuationPrompt = s.muted(' \\ ');
|
|
1375
|
+
const continuationPromptLen = getVisibleLength(continuationPrompt);
|
|
1376
|
+
const cursorLineIndex = lines.length + this.currentLine; // Index where cursor is
|
|
1377
|
+
for (let i = 0; i < this.lines.length; i++) {
|
|
1378
|
+
const linePrompt = i === 0 ? this.promptPrefix : continuationPrompt;
|
|
1379
|
+
const linePromptLen = i === 0 ? this.promptPrefixLen : continuationPromptLen;
|
|
1380
|
+
const lineContent = this.lines[i];
|
|
1381
|
+
// Show ghost text on first line when input is empty and no autocomplete
|
|
1382
|
+
let fullLine;
|
|
1383
|
+
let totalLen;
|
|
1384
|
+
if (i === 0 &&
|
|
1385
|
+
lineContent === '' &&
|
|
1386
|
+
this.lines.length === 1 &&
|
|
1387
|
+
this.suggestion &&
|
|
1388
|
+
!this.autocomplete.active &&
|
|
1389
|
+
!this.fileAutocomplete.active) {
|
|
1390
|
+
// Build: [prompt][suggestion]...[↵ send]
|
|
1391
|
+
const rightHint = '↵ send';
|
|
1392
|
+
const leftContent = linePrompt + s.muted(this.suggestion);
|
|
1393
|
+
const leftLen = linePromptLen + this.suggestion.length;
|
|
1394
|
+
const rightLen = rightHint.length;
|
|
1395
|
+
const padding = Math.max(1, termWidth - leftLen - rightLen);
|
|
1396
|
+
fullLine = leftContent + ' '.repeat(padding) + s.muted(rightHint);
|
|
1397
|
+
totalLen = termWidth; // Full width
|
|
1398
|
+
}
|
|
1399
|
+
else {
|
|
1400
|
+
fullLine = linePrompt + lineContent;
|
|
1401
|
+
totalLen = linePromptLen + getVisibleLength(lineContent);
|
|
1402
|
+
}
|
|
1403
|
+
const physicalLines = totalLen === 0 ? 1 : Math.ceil(totalLen / termWidth) || 1;
|
|
1404
|
+
lines.push({
|
|
1405
|
+
content: fullLine,
|
|
1406
|
+
physicalLines,
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
// 6. Bottom separator
|
|
1410
|
+
lines.push({ content: separator, physicalLines: 1 });
|
|
1411
|
+
// 7. Mode indicator
|
|
1412
|
+
const modeLine = this.buildModeLine(termWidth);
|
|
1413
|
+
lines.push({
|
|
1414
|
+
content: modeLine,
|
|
1415
|
+
physicalLines: getPhysicalLineCount(modeLine, termWidth),
|
|
1416
|
+
});
|
|
1417
|
+
// 8. Autocomplete dropdown (if active) - file autocomplete takes priority
|
|
1418
|
+
const fileAutocompleteLines = this.buildFileAutocompleteLines(termWidth);
|
|
1419
|
+
const autocompleteLines = fileAutocompleteLines.length > 0
|
|
1420
|
+
? fileAutocompleteLines
|
|
1421
|
+
: this.buildAutocompleteLines(termWidth);
|
|
1422
|
+
for (const line of autocompleteLines) {
|
|
1423
|
+
lines.push({
|
|
1424
|
+
content: line,
|
|
1425
|
+
physicalLines: getPhysicalLineCount(line, termWidth),
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
// Calculate total physical height
|
|
1429
|
+
let totalPhysicalLines = 0;
|
|
1430
|
+
for (const line of lines) {
|
|
1431
|
+
totalPhysicalLines += line.physicalLines;
|
|
1432
|
+
}
|
|
1433
|
+
// Clear existing footer
|
|
1434
|
+
this.clear();
|
|
1435
|
+
// Write all lines
|
|
1436
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1437
|
+
terminal.write(lines[i].content);
|
|
1438
|
+
if (i < lines.length - 1) {
|
|
1439
|
+
terminal.write('\n');
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
// Calculate cursor position (for multiline, position on the line where cursor is)
|
|
1443
|
+
let rowsAfterCursor = 0;
|
|
1444
|
+
for (let i = cursorLineIndex + 1; i < lines.length; i++) {
|
|
1445
|
+
rowsAfterCursor += lines[i].physicalLines;
|
|
1446
|
+
}
|
|
1447
|
+
if (rowsAfterCursor > 0) {
|
|
1448
|
+
terminal.moveCursorUp(rowsAfterCursor);
|
|
1449
|
+
}
|
|
1450
|
+
// Calculate column within current line
|
|
1451
|
+
const currentLinePromptLen = this.currentLine === 0 ? this.promptPrefixLen : continuationPromptLen;
|
|
1452
|
+
const totalPos = currentLinePromptLen + this.cursorPos;
|
|
1453
|
+
const cursorCol = (totalPos % termWidth) + 1;
|
|
1454
|
+
terminal.moveCursorToColumn(cursorCol);
|
|
1455
|
+
terminal.showCursor();
|
|
1456
|
+
this.lastRenderHeight = totalPhysicalLines;
|
|
1457
|
+
this.cursorLineFromBottom = rowsAfterCursor;
|
|
1458
|
+
}
|
|
1459
|
+
// ===========================================================================
|
|
1460
|
+
// Private - Line builders
|
|
1461
|
+
// ===========================================================================
|
|
1462
|
+
buildSpinnerLine() {
|
|
1463
|
+
const s = getStyles();
|
|
1464
|
+
const frame = this.spinnerFrames[this.spinnerFrame % this.spinnerFrames.length];
|
|
1465
|
+
const text = this.currentTool ?? this.spinnerText ?? 'Thinking...';
|
|
1466
|
+
// Build hints
|
|
1467
|
+
const hints = ['esc to interrupt'];
|
|
1468
|
+
if (this.todos.length > 0 && this.showTodos) {
|
|
1469
|
+
hints.push('ctrl+t to hide todos');
|
|
1470
|
+
}
|
|
1471
|
+
else if (this.todos.length > 0 && !this.showTodos) {
|
|
1472
|
+
hints.push('ctrl+t to show todos');
|
|
1473
|
+
}
|
|
1474
|
+
const hintsText = hints.length > 0 ? ` (${hints.join(' · ')})` : '';
|
|
1475
|
+
// Format: [CRT scanner] text (hints)
|
|
1476
|
+
return s.muted(`${frame} ${text}${hintsText}`);
|
|
1477
|
+
}
|
|
1478
|
+
buildTodoLine(todo) {
|
|
1479
|
+
const s = getStyles();
|
|
1480
|
+
const icon = todo.status === 'completed' ? '✓' :
|
|
1481
|
+
todo.status === 'in_progress' ? '→' : '☐';
|
|
1482
|
+
const style = todo.status === 'completed' ? s.muted :
|
|
1483
|
+
todo.status === 'in_progress' ? s.info : s.muted;
|
|
1484
|
+
return style(`${icon} ${todo.content}`);
|
|
1485
|
+
}
|
|
1486
|
+
buildModeLine(termWidth) {
|
|
1487
|
+
const s = getStyles();
|
|
1488
|
+
const modeInfo = MODE_INFO[this.mode];
|
|
1489
|
+
let leftPart;
|
|
1490
|
+
switch (this.mode) {
|
|
1491
|
+
case 'normal':
|
|
1492
|
+
leftPart = s.muted(`mode: ${modeInfo.label} (Shift+Tab to change)`);
|
|
1493
|
+
break;
|
|
1494
|
+
case 'auto-accept':
|
|
1495
|
+
leftPart = s.warning(`mode: ${modeInfo.label}`) + s.muted(' (Shift+Tab to change)');
|
|
1496
|
+
break;
|
|
1497
|
+
case 'plan':
|
|
1498
|
+
leftPart = s.info(`mode: ${modeInfo.label}`) + s.muted(' (Shift+Tab to change)');
|
|
1499
|
+
break;
|
|
1500
|
+
default:
|
|
1501
|
+
leftPart = s.muted(`mode: ${modeInfo.label} (Shift+Tab to change)`);
|
|
1502
|
+
}
|
|
1503
|
+
if (!this.projectName) {
|
|
1504
|
+
return leftPart;
|
|
1505
|
+
}
|
|
1506
|
+
const projectText = `Project: ${this.projectName}`;
|
|
1507
|
+
const rightPart = s.muted(projectText);
|
|
1508
|
+
const leftVisible = getVisibleLength(leftPart);
|
|
1509
|
+
const padding = Math.max(2, termWidth - leftVisible - projectText.length);
|
|
1510
|
+
return leftPart + ' '.repeat(padding) + rightPart;
|
|
1511
|
+
}
|
|
1512
|
+
buildAutocompleteLines(termWidth) {
|
|
1513
|
+
if (!this.autocomplete.active || this.autocomplete.matches.length === 0) {
|
|
1514
|
+
return [];
|
|
1515
|
+
}
|
|
1516
|
+
const s = getStyles();
|
|
1517
|
+
const lines = [];
|
|
1518
|
+
const { matches, selectedIndex, scrollOffset } = this.autocomplete;
|
|
1519
|
+
const visible = matches.slice(scrollOffset, scrollOffset + MAX_VISIBLE_COMMANDS);
|
|
1520
|
+
const total = matches.length;
|
|
1521
|
+
// Show scroll indicator if there are more items above
|
|
1522
|
+
if (scrollOffset > 0) {
|
|
1523
|
+
lines.push(s.muted(` ↑ ${String(scrollOffset)} more above`));
|
|
1524
|
+
}
|
|
1525
|
+
for (let i = 0; i < visible.length; i++) {
|
|
1526
|
+
const cmd = visible[i];
|
|
1527
|
+
const actualIndex = scrollOffset + i;
|
|
1528
|
+
const isSelected = actualIndex === selectedIndex;
|
|
1529
|
+
const prefix = isSelected ? s.primary('❯ ') : ' ';
|
|
1530
|
+
const name = isSelected ? s.primaryBold(cmd.command) : cmd.command;
|
|
1531
|
+
// Truncate if too long
|
|
1532
|
+
const baseLen = getVisibleLength(prefix) + getVisibleLength(name) + 3; // 3 for ' - '
|
|
1533
|
+
const maxDescLen = termWidth - baseLen - 2;
|
|
1534
|
+
const truncatedDesc = cmd.description.length > maxDescLen
|
|
1535
|
+
? cmd.description.slice(0, maxDescLen - 1) + '…'
|
|
1536
|
+
: cmd.description;
|
|
1537
|
+
lines.push(`${prefix}${name} ${s.muted('- ' + truncatedDesc)}`);
|
|
1538
|
+
}
|
|
1539
|
+
// Show scroll indicator if there are more items below
|
|
1540
|
+
const belowCount = total - scrollOffset - visible.length;
|
|
1541
|
+
if (belowCount > 0) {
|
|
1542
|
+
lines.push(s.muted(` ↓ ${String(belowCount)} more below`));
|
|
1543
|
+
}
|
|
1544
|
+
return lines;
|
|
1545
|
+
}
|
|
1546
|
+
buildFileAutocompleteLines(termWidth) {
|
|
1547
|
+
if (!this.fileAutocomplete.active || this.fileAutocomplete.matches.length === 0) {
|
|
1548
|
+
return [];
|
|
1549
|
+
}
|
|
1550
|
+
const s = getStyles();
|
|
1551
|
+
const lines = [];
|
|
1552
|
+
const { matches, selectedIndex, scrollOffset } = this.fileAutocomplete;
|
|
1553
|
+
const visible = matches.slice(scrollOffset, scrollOffset + MAX_VISIBLE_COMMANDS);
|
|
1554
|
+
const total = matches.length;
|
|
1555
|
+
// Show scroll indicator if there are more items above
|
|
1556
|
+
if (scrollOffset > 0) {
|
|
1557
|
+
lines.push(s.muted(` ↑ ${String(scrollOffset)} more above`));
|
|
1558
|
+
}
|
|
1559
|
+
for (let i = 0; i < visible.length; i++) {
|
|
1560
|
+
const file = visible[i];
|
|
1561
|
+
const actualIndex = scrollOffset + i;
|
|
1562
|
+
const isSelected = actualIndex === selectedIndex;
|
|
1563
|
+
const prefix = isSelected ? s.primary('❯ ') : ' ';
|
|
1564
|
+
const icon = file.isDirectory ? '📁 ' : '📄 ';
|
|
1565
|
+
const name = isSelected ? s.primaryBold(file.path) : file.path;
|
|
1566
|
+
// Truncate if too long
|
|
1567
|
+
const baseLen = getVisibleLength(prefix) + 3 + getVisibleLength(file.path); // 3 for icon
|
|
1568
|
+
if (baseLen > termWidth - 2) {
|
|
1569
|
+
const maxLen = termWidth - getVisibleLength(prefix) - 5; // 3 for icon, 2 for padding
|
|
1570
|
+
const truncatedPath = file.path.length > maxLen
|
|
1571
|
+
? '…' + file.path.slice(-(maxLen - 1))
|
|
1572
|
+
: file.path;
|
|
1573
|
+
const truncatedName = isSelected ? s.primaryBold(truncatedPath) : truncatedPath;
|
|
1574
|
+
lines.push(`${prefix}${icon}${truncatedName}`);
|
|
1575
|
+
}
|
|
1576
|
+
else {
|
|
1577
|
+
lines.push(`${prefix}${icon}${name}`);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
// Show scroll indicator if there are more items below
|
|
1581
|
+
const belowCount = total - scrollOffset - visible.length;
|
|
1582
|
+
if (belowCount > 0) {
|
|
1583
|
+
lines.push(s.muted(` ↓ ${String(belowCount)} more below`));
|
|
1584
|
+
}
|
|
1585
|
+
return lines;
|
|
1586
|
+
}
|
|
1587
|
+
// ===========================================================================
|
|
1588
|
+
// Private - Cursor calculations
|
|
1589
|
+
// ===========================================================================
|
|
1590
|
+
// ===========================================================================
|
|
1591
|
+
// Private - Autocomplete
|
|
1592
|
+
// ===========================================================================
|
|
1593
|
+
updateAutocomplete() {
|
|
1594
|
+
const currentLine = this.getCurrentLineContent();
|
|
1595
|
+
// Check for @ file path autocomplete first (works on any line)
|
|
1596
|
+
const atMention = extractAtMention(currentLine, this.cursorPos);
|
|
1597
|
+
if (atMention !== null) {
|
|
1598
|
+
// File autocomplete mode - disable command autocomplete
|
|
1599
|
+
this.autocomplete.active = false;
|
|
1600
|
+
this.autocomplete.matches = [];
|
|
1601
|
+
this.autocomplete.selectedIndex = 0;
|
|
1602
|
+
this.autocomplete.scrollOffset = 0;
|
|
1603
|
+
// Enable file autocomplete
|
|
1604
|
+
this.fileAutocomplete.active = true;
|
|
1605
|
+
this.fileAutocomplete.partial = atMention;
|
|
1606
|
+
this.fileAutocomplete.matches = getFileMatches(atMention);
|
|
1607
|
+
if (this.fileAutocomplete.selectedIndex >= this.fileAutocomplete.matches.length) {
|
|
1608
|
+
this.fileAutocomplete.selectedIndex = Math.max(0, this.fileAutocomplete.matches.length - 1);
|
|
1609
|
+
this.fileAutocomplete.scrollOffset = 0;
|
|
1610
|
+
}
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
// Reset file autocomplete
|
|
1614
|
+
this.fileAutocomplete.active = false;
|
|
1615
|
+
this.fileAutocomplete.matches = [];
|
|
1616
|
+
this.fileAutocomplete.selectedIndex = 0;
|
|
1617
|
+
this.fileAutocomplete.scrollOffset = 0;
|
|
1618
|
+
this.fileAutocomplete.partial = '';
|
|
1619
|
+
// Activate command autocomplete when first line starts with /
|
|
1620
|
+
const firstLine = this.lines[0];
|
|
1621
|
+
if (this.currentLine === 0 && firstLine.startsWith('/')) {
|
|
1622
|
+
this.autocomplete.active = true;
|
|
1623
|
+
const freshCommands = getAutocompleteCommands();
|
|
1624
|
+
this.autocomplete.matches = filterCommands(firstLine, freshCommands);
|
|
1625
|
+
// Reset selection if it's out of bounds
|
|
1626
|
+
if (this.autocomplete.selectedIndex >= this.autocomplete.matches.length) {
|
|
1627
|
+
this.autocomplete.selectedIndex = Math.max(0, this.autocomplete.matches.length - 1);
|
|
1628
|
+
this.autocomplete.scrollOffset = 0;
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
else {
|
|
1632
|
+
this.autocomplete.active = false;
|
|
1633
|
+
this.autocomplete.matches = [];
|
|
1634
|
+
this.autocomplete.selectedIndex = 0;
|
|
1635
|
+
this.autocomplete.scrollOffset = 0;
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
acceptAutocomplete() {
|
|
1639
|
+
// File autocomplete takes priority (works on current line)
|
|
1640
|
+
if (this.fileAutocomplete.active && this.fileAutocomplete.matches.length > 0) {
|
|
1641
|
+
const selectedFile = this.fileAutocomplete.matches[this.fileAutocomplete.selectedIndex];
|
|
1642
|
+
const currentLine = this.getCurrentLineContent();
|
|
1643
|
+
const result = replaceAtMention(currentLine, this.cursorPos, selectedFile.path);
|
|
1644
|
+
this.lines[this.currentLine] = result.input;
|
|
1645
|
+
this.cursorPos = result.cursorPos;
|
|
1646
|
+
this.fileAutocomplete.active = false;
|
|
1647
|
+
this.fileAutocomplete.matches = [];
|
|
1648
|
+
this.fileAutocomplete.selectedIndex = 0;
|
|
1649
|
+
this.fileAutocomplete.scrollOffset = 0;
|
|
1650
|
+
this.fileAutocomplete.partial = '';
|
|
1651
|
+
// Check if still in @ context (e.g., directory selected, might want to continue)
|
|
1652
|
+
this.updateAutocomplete();
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
// Command autocomplete (works on first line only)
|
|
1656
|
+
if (this.autocomplete.active && this.autocomplete.matches.length > 0) {
|
|
1657
|
+
const selected = this.autocomplete.matches[this.autocomplete.selectedIndex];
|
|
1658
|
+
this.lines[0] = selected.command;
|
|
1659
|
+
this.currentLine = 0;
|
|
1660
|
+
this.cursorPos = this.lines[0].length;
|
|
1661
|
+
this.autocomplete.active = false;
|
|
1662
|
+
this.autocomplete.matches = [];
|
|
1663
|
+
this.autocomplete.selectedIndex = 0;
|
|
1664
|
+
this.autocomplete.scrollOffset = 0;
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
closeAutocomplete() {
|
|
1668
|
+
// Close file autocomplete
|
|
1669
|
+
this.fileAutocomplete.active = false;
|
|
1670
|
+
this.fileAutocomplete.matches = [];
|
|
1671
|
+
this.fileAutocomplete.selectedIndex = 0;
|
|
1672
|
+
this.fileAutocomplete.scrollOffset = 0;
|
|
1673
|
+
this.fileAutocomplete.partial = '';
|
|
1674
|
+
// Close command autocomplete
|
|
1675
|
+
this.autocomplete.active = false;
|
|
1676
|
+
this.autocomplete.matches = [];
|
|
1677
|
+
this.autocomplete.selectedIndex = 0;
|
|
1678
|
+
this.autocomplete.scrollOffset = 0;
|
|
1679
|
+
}
|
|
1680
|
+
navigateAutocompleteUp() {
|
|
1681
|
+
// File autocomplete navigation takes priority
|
|
1682
|
+
if (this.fileAutocomplete.active && this.fileAutocomplete.matches.length > 0) {
|
|
1683
|
+
if (this.fileAutocomplete.selectedIndex > 0) {
|
|
1684
|
+
this.fileAutocomplete.selectedIndex--;
|
|
1685
|
+
if (this.fileAutocomplete.selectedIndex < this.fileAutocomplete.scrollOffset) {
|
|
1686
|
+
this.fileAutocomplete.scrollOffset = this.fileAutocomplete.selectedIndex;
|
|
1687
|
+
}
|
|
1688
|
+
return true;
|
|
1689
|
+
}
|
|
1690
|
+
return false;
|
|
1691
|
+
}
|
|
1692
|
+
// Command autocomplete navigation
|
|
1693
|
+
if (!this.autocomplete.active || this.autocomplete.matches.length === 0) {
|
|
1694
|
+
return false;
|
|
1695
|
+
}
|
|
1696
|
+
if (this.autocomplete.selectedIndex > 0) {
|
|
1697
|
+
this.autocomplete.selectedIndex--;
|
|
1698
|
+
// Adjust scroll if selection goes above visible area
|
|
1699
|
+
if (this.autocomplete.selectedIndex < this.autocomplete.scrollOffset) {
|
|
1700
|
+
this.autocomplete.scrollOffset = this.autocomplete.selectedIndex;
|
|
1701
|
+
}
|
|
1702
|
+
return true;
|
|
1703
|
+
}
|
|
1704
|
+
return false;
|
|
1705
|
+
}
|
|
1706
|
+
navigateAutocompleteDown() {
|
|
1707
|
+
// File autocomplete navigation takes priority
|
|
1708
|
+
if (this.fileAutocomplete.active && this.fileAutocomplete.matches.length > 0) {
|
|
1709
|
+
if (this.fileAutocomplete.selectedIndex < this.fileAutocomplete.matches.length - 1) {
|
|
1710
|
+
this.fileAutocomplete.selectedIndex++;
|
|
1711
|
+
const maxVisibleIndex = this.fileAutocomplete.scrollOffset + MAX_VISIBLE_COMMANDS - 1;
|
|
1712
|
+
if (this.fileAutocomplete.selectedIndex > maxVisibleIndex) {
|
|
1713
|
+
this.fileAutocomplete.scrollOffset = this.fileAutocomplete.selectedIndex - MAX_VISIBLE_COMMANDS + 1;
|
|
1714
|
+
}
|
|
1715
|
+
return true;
|
|
1716
|
+
}
|
|
1717
|
+
return false;
|
|
1718
|
+
}
|
|
1719
|
+
// Command autocomplete navigation
|
|
1720
|
+
if (!this.autocomplete.active || this.autocomplete.matches.length === 0) {
|
|
1721
|
+
return false;
|
|
1722
|
+
}
|
|
1723
|
+
if (this.autocomplete.selectedIndex < this.autocomplete.matches.length - 1) {
|
|
1724
|
+
this.autocomplete.selectedIndex++;
|
|
1725
|
+
// Adjust scroll if selection goes below visible area
|
|
1726
|
+
const maxVisibleIndex = this.autocomplete.scrollOffset + MAX_VISIBLE_COMMANDS - 1;
|
|
1727
|
+
if (this.autocomplete.selectedIndex > maxVisibleIndex) {
|
|
1728
|
+
this.autocomplete.scrollOffset = this.autocomplete.selectedIndex - MAX_VISIBLE_COMMANDS + 1;
|
|
1729
|
+
}
|
|
1730
|
+
return true;
|
|
1731
|
+
}
|
|
1732
|
+
return false;
|
|
1733
|
+
}
|
|
1734
|
+
// ===========================================================================
|
|
1735
|
+
// Private - History
|
|
1736
|
+
// ===========================================================================
|
|
1737
|
+
addToHistory(input) {
|
|
1738
|
+
const trimmed = input.trim();
|
|
1739
|
+
// Don't add empty or duplicate consecutive entries
|
|
1740
|
+
if (trimmed && (this.history.length === 0 || this.history[this.history.length - 1] !== trimmed)) {
|
|
1741
|
+
this.history.push(trimmed);
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
navigateHistoryUp() {
|
|
1745
|
+
if (this.autocomplete.active)
|
|
1746
|
+
return false;
|
|
1747
|
+
if (this.history.length === 0)
|
|
1748
|
+
return false;
|
|
1749
|
+
// Save current input when starting to navigate
|
|
1750
|
+
if (this.historyIndex === -1) {
|
|
1751
|
+
this.savedInput = this.getInputValue();
|
|
1752
|
+
}
|
|
1753
|
+
// Navigate to older entry
|
|
1754
|
+
if (this.historyIndex < this.history.length - 1) {
|
|
1755
|
+
this.historyIndex++;
|
|
1756
|
+
const historyEntry = this.history[this.history.length - 1 - this.historyIndex];
|
|
1757
|
+
// History entries are single-line (newlines stripped on save)
|
|
1758
|
+
this.lines = [historyEntry];
|
|
1759
|
+
this.currentLine = 0;
|
|
1760
|
+
this.cursorPos = historyEntry.length;
|
|
1761
|
+
return true;
|
|
1762
|
+
}
|
|
1763
|
+
return false;
|
|
1764
|
+
}
|
|
1765
|
+
navigateHistoryDown() {
|
|
1766
|
+
if (this.autocomplete.active)
|
|
1767
|
+
return false;
|
|
1768
|
+
if (this.historyIndex < 0)
|
|
1769
|
+
return false;
|
|
1770
|
+
this.historyIndex--;
|
|
1771
|
+
if (this.historyIndex === -1) {
|
|
1772
|
+
// Restore saved input (may be multiline)
|
|
1773
|
+
this.lines = this.savedInput.split('\n');
|
|
1774
|
+
if (this.lines.length === 0)
|
|
1775
|
+
this.lines = [''];
|
|
1776
|
+
this.currentLine = this.lines.length - 1;
|
|
1777
|
+
this.cursorPos = this.lines[this.currentLine].length;
|
|
1778
|
+
}
|
|
1779
|
+
else {
|
|
1780
|
+
// Navigate to newer entry
|
|
1781
|
+
const historyEntry = this.history[this.history.length - 1 - this.historyIndex];
|
|
1782
|
+
this.lines = [historyEntry];
|
|
1783
|
+
this.currentLine = 0;
|
|
1784
|
+
this.cursorPos = historyEntry.length;
|
|
1785
|
+
}
|
|
1786
|
+
return true;
|
|
1787
|
+
}
|
|
1788
|
+
resetHistoryNavigation() {
|
|
1789
|
+
this.historyIndex = -1;
|
|
1790
|
+
this.savedInput = '';
|
|
1791
|
+
}
|
|
1792
|
+
// ===========================================================================
|
|
1793
|
+
// Private - Spinner animation
|
|
1794
|
+
// ===========================================================================
|
|
1795
|
+
startSpinnerAnimation() {
|
|
1796
|
+
if (this.spinnerTimer)
|
|
1797
|
+
return;
|
|
1798
|
+
this.spinnerTimer = setInterval(() => {
|
|
1799
|
+
this.spinnerFrame++;
|
|
1800
|
+
this.needsRender = true;
|
|
1801
|
+
}, 200); // Slower, more relaxed animation
|
|
1802
|
+
}
|
|
1803
|
+
stopSpinnerAnimation() {
|
|
1804
|
+
if (this.spinnerTimer) {
|
|
1805
|
+
clearInterval(this.spinnerTimer);
|
|
1806
|
+
this.spinnerTimer = null;
|
|
1807
|
+
}
|
|
1808
|
+
this.spinnerFrame = 0;
|
|
1809
|
+
}
|
|
1810
|
+
// ===========================================================================
|
|
1811
|
+
// Private - Keyboard handling
|
|
1812
|
+
// ===========================================================================
|
|
1813
|
+
keyHandler = null;
|
|
1814
|
+
dataHandler = null;
|
|
1815
|
+
startKeyboardCapture() {
|
|
1816
|
+
if (this.keyHandler)
|
|
1817
|
+
return; // Already capturing
|
|
1818
|
+
readline.emitKeypressEvents(process.stdin);
|
|
1819
|
+
if (process.stdin.isTTY) {
|
|
1820
|
+
process.stdin.setRawMode(true);
|
|
1821
|
+
}
|
|
1822
|
+
// Handle raw data for reliable Escape and Option+Arrow detection
|
|
1823
|
+
this.dataHandler = (data) => {
|
|
1824
|
+
if (!this.isRunning || this.isPaused)
|
|
1825
|
+
return;
|
|
1826
|
+
// Pure Escape key is a single byte 0x1B
|
|
1827
|
+
if (data.length === 1 && data[0] === 0x1b) {
|
|
1828
|
+
this.handleEscape();
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
// Mac Option+Left (word left): ESC b or ESC [ 1 ; 9 D
|
|
1832
|
+
const isOptionLeft = (data.length === 2 && data[0] === 0x1b && data[1] === 0x62) ||
|
|
1833
|
+
(data.length === 6 &&
|
|
1834
|
+
data[0] === 0x1b &&
|
|
1835
|
+
data[1] === 0x5b &&
|
|
1836
|
+
data[2] === 0x31 &&
|
|
1837
|
+
data[3] === 0x3b &&
|
|
1838
|
+
data[4] === 0x39 &&
|
|
1839
|
+
data[5] === 0x44);
|
|
1840
|
+
// Mac Option+Right (word right): ESC f or ESC [ 1 ; 9 C
|
|
1841
|
+
const isOptionRight = (data.length === 2 && data[0] === 0x1b && data[1] === 0x66) ||
|
|
1842
|
+
(data.length === 6 &&
|
|
1843
|
+
data[0] === 0x1b &&
|
|
1844
|
+
data[1] === 0x5b &&
|
|
1845
|
+
data[2] === 0x31 &&
|
|
1846
|
+
data[3] === 0x3b &&
|
|
1847
|
+
data[4] === 0x39 &&
|
|
1848
|
+
data[5] === 0x43);
|
|
1849
|
+
if (isOptionLeft) {
|
|
1850
|
+
this.handleWordLeft();
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
if (isOptionRight) {
|
|
1854
|
+
this.handleWordRight();
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1857
|
+
// Home/Cmd+Left: Ctrl+A (\x01) or ESC [ H
|
|
1858
|
+
const isHome = (data.length === 1 && data[0] === 0x01) ||
|
|
1859
|
+
(data.length === 3 && data[0] === 0x1b && data[1] === 0x5b && data[2] === 0x48);
|
|
1860
|
+
// End/Cmd+Right: Ctrl+E (\x05) or ESC [ F
|
|
1861
|
+
const isEnd = (data.length === 1 && data[0] === 0x05) ||
|
|
1862
|
+
(data.length === 3 && data[0] === 0x1b && data[1] === 0x5b && data[2] === 0x46);
|
|
1863
|
+
if (isHome) {
|
|
1864
|
+
this.cursorPos = 0;
|
|
1865
|
+
this.needsRender = true;
|
|
1866
|
+
return;
|
|
1867
|
+
}
|
|
1868
|
+
if (isEnd) {
|
|
1869
|
+
this.cursorPos = this.lines[this.currentLine].length;
|
|
1870
|
+
this.needsRender = true;
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
};
|
|
1874
|
+
this.keyHandler = (str, key) => {
|
|
1875
|
+
if (!this.isRunning || this.isPaused)
|
|
1876
|
+
return;
|
|
1877
|
+
// Skip escape here - handled by dataHandler
|
|
1878
|
+
if (key.name === 'escape')
|
|
1879
|
+
return;
|
|
1880
|
+
this.handleKeypress(str, key);
|
|
1881
|
+
};
|
|
1882
|
+
process.stdin.on('data', this.dataHandler);
|
|
1883
|
+
process.stdin.on('keypress', this.keyHandler);
|
|
1884
|
+
process.stdin.resume();
|
|
1885
|
+
}
|
|
1886
|
+
stopKeyboardCapture() {
|
|
1887
|
+
if (this.dataHandler) {
|
|
1888
|
+
process.stdin.removeListener('data', this.dataHandler);
|
|
1889
|
+
this.dataHandler = null;
|
|
1890
|
+
}
|
|
1891
|
+
if (this.keyHandler) {
|
|
1892
|
+
process.stdin.removeListener('keypress', this.keyHandler);
|
|
1893
|
+
this.keyHandler = null;
|
|
1894
|
+
}
|
|
1895
|
+
if (process.stdin.isTTY) {
|
|
1896
|
+
process.stdin.setRawMode(false);
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
/**
|
|
1900
|
+
* Handle word left (Option+Left) - move cursor to previous word boundary
|
|
1901
|
+
*/
|
|
1902
|
+
handleWordLeft() {
|
|
1903
|
+
const line = this.lines[this.currentLine];
|
|
1904
|
+
if (this.cursorPos > 0) {
|
|
1905
|
+
let pos = this.cursorPos;
|
|
1906
|
+
// Skip spaces
|
|
1907
|
+
while (pos > 0 && line[pos - 1] === ' ')
|
|
1908
|
+
pos--;
|
|
1909
|
+
// Skip word
|
|
1910
|
+
while (pos > 0 && line[pos - 1] !== ' ')
|
|
1911
|
+
pos--;
|
|
1912
|
+
this.cursorPos = pos;
|
|
1913
|
+
this.needsRender = true;
|
|
1914
|
+
}
|
|
1915
|
+
else if (this.currentLine > 0) {
|
|
1916
|
+
// Move to end of previous line
|
|
1917
|
+
this.currentLine--;
|
|
1918
|
+
this.cursorPos = this.lines[this.currentLine].length;
|
|
1919
|
+
this.needsRender = true;
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
/**
|
|
1923
|
+
* Handle word right (Option+Right) - move cursor to next word boundary
|
|
1924
|
+
*/
|
|
1925
|
+
handleWordRight() {
|
|
1926
|
+
const line = this.lines[this.currentLine];
|
|
1927
|
+
if (this.cursorPos < line.length) {
|
|
1928
|
+
let pos = this.cursorPos;
|
|
1929
|
+
// Skip word
|
|
1930
|
+
while (pos < line.length && line[pos] !== ' ')
|
|
1931
|
+
pos++;
|
|
1932
|
+
// Skip spaces
|
|
1933
|
+
while (pos < line.length && line[pos] === ' ')
|
|
1934
|
+
pos++;
|
|
1935
|
+
this.cursorPos = pos;
|
|
1936
|
+
this.needsRender = true;
|
|
1937
|
+
}
|
|
1938
|
+
else if (this.currentLine < this.lines.length - 1) {
|
|
1939
|
+
// Move to start of next line
|
|
1940
|
+
this.currentLine++;
|
|
1941
|
+
this.cursorPos = 0;
|
|
1942
|
+
this.needsRender = true;
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
/**
|
|
1946
|
+
* Handle Escape key - called from raw data handler for reliable detection
|
|
1947
|
+
*/
|
|
1948
|
+
handleEscape() {
|
|
1949
|
+
// In verbose-temp mode, escape returns to normal
|
|
1950
|
+
if (this.viewMode === 'verbose-temp') {
|
|
1951
|
+
this.viewMode = 'normal';
|
|
1952
|
+
this.reRenderConversationVerbose(false);
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1955
|
+
// Route escape to active overlay if present
|
|
1956
|
+
const overlay = this.getActiveOverlay();
|
|
1957
|
+
if (overlay) {
|
|
1958
|
+
const keyEvent = {
|
|
1959
|
+
raw: Buffer.from([0x1b]),
|
|
1960
|
+
name: 'escape',
|
|
1961
|
+
ctrl: false,
|
|
1962
|
+
shift: false,
|
|
1963
|
+
meta: false,
|
|
1964
|
+
};
|
|
1965
|
+
const actionOrPromise = overlay.handleKey(keyEvent);
|
|
1966
|
+
// Handle both sync and async handleKey
|
|
1967
|
+
if (actionOrPromise instanceof Promise) {
|
|
1968
|
+
actionOrPromise
|
|
1969
|
+
.then((action) => { this.processOverlayAction(action); })
|
|
1970
|
+
.catch((err) => { console.error('Overlay handleKey error:', err); });
|
|
1971
|
+
}
|
|
1972
|
+
else {
|
|
1973
|
+
this.processOverlayAction(actionOrPromise);
|
|
1974
|
+
}
|
|
1975
|
+
return;
|
|
1976
|
+
}
|
|
1977
|
+
const now = Date.now();
|
|
1978
|
+
const timeSinceLastEsc = now - this.lastEscapeTime;
|
|
1979
|
+
const isDoubleEsc = timeSinceLastEsc < 500;
|
|
1980
|
+
this.lastEscapeTime = now;
|
|
1981
|
+
if (this.fileAutocomplete.active) {
|
|
1982
|
+
// 1. Close file autocomplete first
|
|
1983
|
+
this.closeAutocomplete();
|
|
1984
|
+
this.needsRender = true;
|
|
1985
|
+
}
|
|
1986
|
+
else if (this.autocomplete.active) {
|
|
1987
|
+
// 2. Close command autocomplete
|
|
1988
|
+
this.closeAutocomplete();
|
|
1989
|
+
this.needsRender = true;
|
|
1990
|
+
}
|
|
1991
|
+
else if (isDoubleEsc && this.getInputValue().length > 0) {
|
|
1992
|
+
// 3. Double Esc clears input (if there's content)
|
|
1993
|
+
this.clearInput();
|
|
1994
|
+
this.resetHistoryNavigation();
|
|
1995
|
+
this.needsRender = true;
|
|
1996
|
+
}
|
|
1997
|
+
else if (this.agentRunning) {
|
|
1998
|
+
// 4. Single Esc cancels agent
|
|
1999
|
+
this.emit('cancel');
|
|
2000
|
+
}
|
|
2001
|
+
else {
|
|
2002
|
+
// 5. Single Esc emits escape
|
|
2003
|
+
this.emit('escape');
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
/**
|
|
2007
|
+
* Handle keypress when overlay is active.
|
|
2008
|
+
* Converts keyboard event to KeyEvent and routes to overlay.
|
|
2009
|
+
*/
|
|
2010
|
+
handleOverlayKeypress(str, key) {
|
|
2011
|
+
const overlay = this.getActiveOverlay();
|
|
2012
|
+
if (!overlay)
|
|
2013
|
+
return;
|
|
2014
|
+
// Build KeyEvent from keypress data
|
|
2015
|
+
const rawData = key.sequence ?? str;
|
|
2016
|
+
const keyName = key.name ?? (str ? str.toLowerCase() : '');
|
|
2017
|
+
const keyEvent = {
|
|
2018
|
+
raw: Buffer.from(rawData),
|
|
2019
|
+
name: keyName,
|
|
2020
|
+
char: str && str.length === 1 && str.charCodeAt(0) >= 32 ? str : undefined,
|
|
2021
|
+
ctrl: key.ctrl ?? false,
|
|
2022
|
+
shift: key.shift ?? false,
|
|
2023
|
+
meta: key.meta ?? false,
|
|
2024
|
+
};
|
|
2025
|
+
const actionOrPromise = overlay.handleKey(keyEvent);
|
|
2026
|
+
// Handle both sync and async handleKey
|
|
2027
|
+
if (actionOrPromise instanceof Promise) {
|
|
2028
|
+
actionOrPromise
|
|
2029
|
+
.then((action) => { this.processOverlayAction(action); })
|
|
2030
|
+
.catch((err) => { console.error('Overlay handleKey error:', err); });
|
|
2031
|
+
}
|
|
2032
|
+
else {
|
|
2033
|
+
this.processOverlayAction(actionOrPromise);
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
handleKeypress(str, key) {
|
|
2037
|
+
// Route to active overlay if present
|
|
2038
|
+
if (this.hasActiveOverlay()) {
|
|
2039
|
+
this.handleOverlayKeypress(str, key);
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
// In verbose-temp mode, any key (except Ctrl+O) returns to normal
|
|
2043
|
+
if (this.viewMode === 'verbose-temp') {
|
|
2044
|
+
// Ctrl+O toggles back
|
|
2045
|
+
if (key.ctrl && key.name === 'o') {
|
|
2046
|
+
this.toggleVerboseView();
|
|
2047
|
+
this.toggleLiveRegionExpanded();
|
|
2048
|
+
return;
|
|
2049
|
+
}
|
|
2050
|
+
// Any other key returns to normal
|
|
2051
|
+
this.viewMode = 'normal';
|
|
2052
|
+
this.reRenderConversationVerbose(false);
|
|
2053
|
+
// Don't consume the key - let it be processed normally
|
|
2054
|
+
}
|
|
2055
|
+
// Ctrl+C - interrupt/exit
|
|
2056
|
+
if (key.ctrl && key.name === 'c') {
|
|
2057
|
+
this.emit('interrupt');
|
|
2058
|
+
return;
|
|
2059
|
+
}
|
|
2060
|
+
// Ctrl+T - toggle todo list visibility
|
|
2061
|
+
if (key.ctrl && key.name === 't') {
|
|
2062
|
+
this.toggleTodos();
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
2065
|
+
// Ctrl+O - toggle verbose view mode AND LiveRegion expansion
|
|
2066
|
+
if (key.ctrl && key.name === 'o') {
|
|
2067
|
+
this.toggleVerboseView();
|
|
2068
|
+
this.toggleLiveRegionExpanded();
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
2071
|
+
// Tab - accept suggestion, or autocomplete
|
|
2072
|
+
if (key.name === 'tab' && !key.shift) {
|
|
2073
|
+
// Accept ghost text suggestion if input is empty
|
|
2074
|
+
if (this.suggestion && this.getInputValue() === '') {
|
|
2075
|
+
this.lines[0] = this.suggestion;
|
|
2076
|
+
this.cursorPos = this.suggestion.length;
|
|
2077
|
+
this.suggestion = null;
|
|
2078
|
+
this.needsRender = true;
|
|
2079
|
+
return;
|
|
2080
|
+
}
|
|
2081
|
+
if (this.fileAutocomplete.active && this.fileAutocomplete.matches.length > 0) {
|
|
2082
|
+
this.acceptAutocomplete();
|
|
2083
|
+
this.needsRender = true;
|
|
2084
|
+
return;
|
|
2085
|
+
}
|
|
2086
|
+
if (this.autocomplete.active && this.autocomplete.matches.length > 0) {
|
|
2087
|
+
this.acceptAutocomplete();
|
|
2088
|
+
this.needsRender = true;
|
|
2089
|
+
return;
|
|
2090
|
+
}
|
|
2091
|
+
// Otherwise ignore tab (or could insert spaces)
|
|
2092
|
+
return;
|
|
2093
|
+
}
|
|
2094
|
+
// Enter - handle multiline continuation, autocomplete, then execute
|
|
2095
|
+
if (key.name === 'return') {
|
|
2096
|
+
// Check for backslash continuation at end of current line
|
|
2097
|
+
const currentLine = this.getCurrentLineContent();
|
|
2098
|
+
if (currentLine.endsWith('\\')) {
|
|
2099
|
+
// Remove backslash and add new line
|
|
2100
|
+
this.lines[this.currentLine] = currentLine.slice(0, -1);
|
|
2101
|
+
this.lines.push('');
|
|
2102
|
+
this.currentLine++;
|
|
2103
|
+
this.cursorPos = 0;
|
|
2104
|
+
this.closeAutocomplete();
|
|
2105
|
+
this.resetHistoryNavigation();
|
|
2106
|
+
this.needsRender = true;
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
2109
|
+
// If file autocomplete is active, accept the selection and submit
|
|
2110
|
+
if (this.fileAutocomplete.active && this.fileAutocomplete.matches.length > 0) {
|
|
2111
|
+
this.acceptAutocomplete();
|
|
2112
|
+
// Fall through to submit the message
|
|
2113
|
+
}
|
|
2114
|
+
// If command autocomplete is active, accept the selection first
|
|
2115
|
+
if (this.autocomplete.active && this.autocomplete.matches.length > 0) {
|
|
2116
|
+
this.acceptAutocomplete();
|
|
2117
|
+
// Fall through to execute the command
|
|
2118
|
+
}
|
|
2119
|
+
let input = this.getInputValue().trim();
|
|
2120
|
+
// If input is empty but we have a ghost text suggestion, accept it
|
|
2121
|
+
if (!input && this.suggestion) {
|
|
2122
|
+
input = this.suggestion;
|
|
2123
|
+
this.suggestion = null;
|
|
2124
|
+
}
|
|
2125
|
+
if (input) {
|
|
2126
|
+
// Add to history before processing (store as single line for history)
|
|
2127
|
+
this.addToHistory(input.replace(/\n/g, ' '));
|
|
2128
|
+
this.resetHistoryNavigation();
|
|
2129
|
+
if (this.agentRunning) {
|
|
2130
|
+
this.queuedInputs.push(input);
|
|
2131
|
+
this.needsRender = true;
|
|
2132
|
+
}
|
|
2133
|
+
else if (input.startsWith('/')) {
|
|
2134
|
+
const spaceIndex = input.indexOf(' ');
|
|
2135
|
+
const cmd = spaceIndex > 0 ? input.slice(1, spaceIndex) : input.slice(1);
|
|
2136
|
+
const args = spaceIndex > 0 ? input.slice(spaceIndex + 1) : '';
|
|
2137
|
+
this.closeAutocomplete();
|
|
2138
|
+
this.emit('command', cmd, args);
|
|
2139
|
+
}
|
|
2140
|
+
else {
|
|
2141
|
+
this.emit('submit', input);
|
|
2142
|
+
}
|
|
2143
|
+
this.clearInput();
|
|
2144
|
+
this.closeAutocomplete();
|
|
2145
|
+
this.needsRender = true;
|
|
2146
|
+
}
|
|
2147
|
+
return;
|
|
2148
|
+
}
|
|
2149
|
+
// Arrow Up - autocomplete > multiline navigation > history
|
|
2150
|
+
if (key.name === 'up') {
|
|
2151
|
+
// 1. Autocomplete navigation takes priority
|
|
2152
|
+
if (this.navigateAutocompleteUp()) {
|
|
2153
|
+
this.needsRender = true;
|
|
2154
|
+
return;
|
|
2155
|
+
}
|
|
2156
|
+
// 2. Navigate to previous line in multiline input
|
|
2157
|
+
if (this.currentLine > 0) {
|
|
2158
|
+
this.currentLine--;
|
|
2159
|
+
// Try to keep same cursor position, but clamp to line length
|
|
2160
|
+
this.cursorPos = Math.min(this.cursorPos, this.lines[this.currentLine].length);
|
|
2161
|
+
this.needsRender = true;
|
|
2162
|
+
return;
|
|
2163
|
+
}
|
|
2164
|
+
// 3. History navigation (only when at first line)
|
|
2165
|
+
if (this.navigateHistoryUp()) {
|
|
2166
|
+
this.closeAutocomplete();
|
|
2167
|
+
this.needsRender = true;
|
|
2168
|
+
}
|
|
2169
|
+
return;
|
|
2170
|
+
}
|
|
2171
|
+
// Arrow Down - autocomplete > multiline navigation > history
|
|
2172
|
+
if (key.name === 'down') {
|
|
2173
|
+
// 1. Autocomplete navigation takes priority
|
|
2174
|
+
if (this.navigateAutocompleteDown()) {
|
|
2175
|
+
this.needsRender = true;
|
|
2176
|
+
return;
|
|
2177
|
+
}
|
|
2178
|
+
// 2. Navigate to next line in multiline input
|
|
2179
|
+
if (this.currentLine < this.lines.length - 1) {
|
|
2180
|
+
this.currentLine++;
|
|
2181
|
+
// Try to keep same cursor position, but clamp to line length
|
|
2182
|
+
this.cursorPos = Math.min(this.cursorPos, this.lines[this.currentLine].length);
|
|
2183
|
+
this.needsRender = true;
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
// 3. History forward (only when at last line)
|
|
2187
|
+
if (this.historyIndex >= 0 && this.navigateHistoryDown()) {
|
|
2188
|
+
this.needsRender = true;
|
|
2189
|
+
}
|
|
2190
|
+
return;
|
|
2191
|
+
}
|
|
2192
|
+
// Backspace
|
|
2193
|
+
if (key.name === 'backspace') {
|
|
2194
|
+
if (this.cursorPos > 0) {
|
|
2195
|
+
// Delete character in current line
|
|
2196
|
+
const line = this.lines[this.currentLine];
|
|
2197
|
+
this.lines[this.currentLine] = line.slice(0, this.cursorPos - 1) + line.slice(this.cursorPos);
|
|
2198
|
+
this.cursorPos--;
|
|
2199
|
+
this.resetHistoryNavigation();
|
|
2200
|
+
this.updateAutocomplete();
|
|
2201
|
+
this.needsRender = true;
|
|
2202
|
+
}
|
|
2203
|
+
else if (this.currentLine > 0) {
|
|
2204
|
+
// At start of line - merge with previous line
|
|
2205
|
+
const currentLine = this.lines[this.currentLine];
|
|
2206
|
+
const prevLine = this.lines[this.currentLine - 1];
|
|
2207
|
+
this.lines[this.currentLine - 1] = prevLine + currentLine;
|
|
2208
|
+
this.lines.splice(this.currentLine, 1);
|
|
2209
|
+
this.currentLine--;
|
|
2210
|
+
this.cursorPos = prevLine.length;
|
|
2211
|
+
this.resetHistoryNavigation();
|
|
2212
|
+
this.updateAutocomplete();
|
|
2213
|
+
this.needsRender = true;
|
|
2214
|
+
}
|
|
2215
|
+
return;
|
|
2216
|
+
}
|
|
2217
|
+
// Delete
|
|
2218
|
+
if (key.name === 'delete') {
|
|
2219
|
+
const line = this.lines[this.currentLine];
|
|
2220
|
+
if (this.cursorPos < line.length) {
|
|
2221
|
+
// Delete character in current line
|
|
2222
|
+
this.lines[this.currentLine] = line.slice(0, this.cursorPos) + line.slice(this.cursorPos + 1);
|
|
2223
|
+
this.resetHistoryNavigation();
|
|
2224
|
+
this.updateAutocomplete();
|
|
2225
|
+
this.needsRender = true;
|
|
2226
|
+
}
|
|
2227
|
+
else if (this.currentLine < this.lines.length - 1) {
|
|
2228
|
+
// At end of line - merge with next line
|
|
2229
|
+
const nextLine = this.lines[this.currentLine + 1];
|
|
2230
|
+
this.lines[this.currentLine] = line + nextLine;
|
|
2231
|
+
this.lines.splice(this.currentLine + 1, 1);
|
|
2232
|
+
this.resetHistoryNavigation();
|
|
2233
|
+
this.updateAutocomplete();
|
|
2234
|
+
this.needsRender = true;
|
|
2235
|
+
}
|
|
2236
|
+
return;
|
|
2237
|
+
}
|
|
2238
|
+
// Arrow keys (left/right for cursor movement with multiline support)
|
|
2239
|
+
if (key.name === 'left') {
|
|
2240
|
+
if (this.cursorPos > 0) {
|
|
2241
|
+
this.cursorPos--;
|
|
2242
|
+
this.needsRender = true;
|
|
2243
|
+
}
|
|
2244
|
+
else if (this.currentLine > 0) {
|
|
2245
|
+
// Move to end of previous line
|
|
2246
|
+
this.currentLine--;
|
|
2247
|
+
this.cursorPos = this.lines[this.currentLine].length;
|
|
2248
|
+
this.needsRender = true;
|
|
2249
|
+
}
|
|
2250
|
+
return;
|
|
2251
|
+
}
|
|
2252
|
+
if (key.name === 'right') {
|
|
2253
|
+
const lineLen = this.lines[this.currentLine].length;
|
|
2254
|
+
if (this.cursorPos < lineLen) {
|
|
2255
|
+
this.cursorPos++;
|
|
2256
|
+
this.needsRender = true;
|
|
2257
|
+
}
|
|
2258
|
+
else if (this.currentLine < this.lines.length - 1) {
|
|
2259
|
+
// Move to start of next line
|
|
2260
|
+
this.currentLine++;
|
|
2261
|
+
this.cursorPos = 0;
|
|
2262
|
+
this.needsRender = true;
|
|
2263
|
+
}
|
|
2264
|
+
return;
|
|
2265
|
+
}
|
|
2266
|
+
if (key.name === 'home') {
|
|
2267
|
+
this.cursorPos = 0;
|
|
2268
|
+
this.needsRender = true;
|
|
2269
|
+
return;
|
|
2270
|
+
}
|
|
2271
|
+
if (key.name === 'end') {
|
|
2272
|
+
this.cursorPos = this.lines[this.currentLine].length;
|
|
2273
|
+
this.needsRender = true;
|
|
2274
|
+
return;
|
|
2275
|
+
}
|
|
2276
|
+
// Shift+Tab - mode change
|
|
2277
|
+
if (key.shift && key.name === 'tab') {
|
|
2278
|
+
this.emit('modeChange');
|
|
2279
|
+
return;
|
|
2280
|
+
}
|
|
2281
|
+
// Regular character
|
|
2282
|
+
if (str && str.length === 1 && str.charCodeAt(0) >= 32) {
|
|
2283
|
+
// Clear ghost text suggestion when user starts typing
|
|
2284
|
+
if (this.suggestion) {
|
|
2285
|
+
this.suggestion = null;
|
|
2286
|
+
}
|
|
2287
|
+
const line = this.lines[this.currentLine];
|
|
2288
|
+
this.lines[this.currentLine] =
|
|
2289
|
+
line.slice(0, this.cursorPos) + str + line.slice(this.cursorPos);
|
|
2290
|
+
this.cursorPos++;
|
|
2291
|
+
this.resetHistoryNavigation();
|
|
2292
|
+
this.updateAutocomplete();
|
|
2293
|
+
this.needsRender = true;
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
}
|