@orbit-intelligence/orbit-agent 0.3.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +23 -0
  3. package/bin/orbit +26 -0
  4. package/dist/prompts/system.js +80 -0
  5. package/dist/src/cli/args.js +145 -0
  6. package/dist/src/cli/orchestrate.js +100 -0
  7. package/dist/src/cli/run.js +393 -0
  8. package/dist/src/config/config-schema.js +151 -0
  9. package/dist/src/config/index.js +57 -0
  10. package/dist/src/core/agent/agent-loop.js +402 -0
  11. package/dist/src/core/agents/delegate.js +120 -0
  12. package/dist/src/core/agents/orchestrator.js +58 -0
  13. package/dist/src/core/agents/prompts.js +82 -0
  14. package/dist/src/core/agents/types.js +1 -0
  15. package/dist/src/core/context/context-manager.js +167 -0
  16. package/dist/src/core/events.js +23 -0
  17. package/dist/src/core/llm/http.js +207 -0
  18. package/dist/src/core/llm/index.js +93 -0
  19. package/dist/src/core/llm/models.js +228 -0
  20. package/dist/src/core/llm/providers/gemini.js +211 -0
  21. package/dist/src/core/llm/providers/openai-compat.js +31 -0
  22. package/dist/src/core/llm/router.js +125 -0
  23. package/dist/src/core/llm/secrets.js +121 -0
  24. package/dist/src/core/llm/types.js +10 -0
  25. package/dist/src/core/orchestration/dispatcher.js +74 -0
  26. package/dist/src/core/orchestration/messenger.js +139 -0
  27. package/dist/src/core/orchestration/roles.js +129 -0
  28. package/dist/src/core/orchestration/runtime.js +122 -0
  29. package/dist/src/core/orchestration/session.js +204 -0
  30. package/dist/src/core/orchestration/shared-context.js +88 -0
  31. package/dist/src/core/orchestration/tools.js +187 -0
  32. package/dist/src/core/orchestration/types.js +3 -0
  33. package/dist/src/core/permissions/index.js +58 -0
  34. package/dist/src/core/project-context.js +115 -0
  35. package/dist/src/core/skill-loader.js +31 -0
  36. package/dist/src/core/tools/edit.js +142 -0
  37. package/dist/src/core/tools/filesystem.js +203 -0
  38. package/dist/src/core/tools/git.js +138 -0
  39. package/dist/src/core/tools/registry.js +73 -0
  40. package/dist/src/core/tools/search.js +90 -0
  41. package/dist/src/core/tools/shell.js +65 -0
  42. package/dist/src/core/tools/types.js +6 -0
  43. package/dist/src/core/types.js +3 -0
  44. package/dist/src/index.js +11 -0
  45. package/dist/src/session/event-log.js +55 -0
  46. package/dist/src/session/store.js +76 -0
  47. package/dist/src/setup/wizard.js +401 -0
  48. package/dist/src/tui/InkApp.js +67 -0
  49. package/dist/src/tui/ansi.js +142 -0
  50. package/dist/src/tui/app.js +768 -0
  51. package/dist/src/tui/colors.js +13 -0
  52. package/dist/src/tui/components/AgentDock.js +46 -0
  53. package/dist/src/tui/components/Composer.js +35 -0
  54. package/dist/src/tui/components/Header.js +23 -0
  55. package/dist/src/tui/components/ModelPicker.js +23 -0
  56. package/dist/src/tui/components/PermissionModal.js +29 -0
  57. package/dist/src/tui/components/SlashMenu.js +15 -0
  58. package/dist/src/tui/components/StatusLine.js +27 -0
  59. package/dist/src/tui/components/Transcript.js +31 -0
  60. package/dist/src/tui/components/WorkingStatus.js +29 -0
  61. package/dist/src/tui/components/input.js +246 -0
  62. package/dist/src/tui/components/markdown.js +384 -0
  63. package/dist/src/tui/components/message.js +105 -0
  64. package/dist/src/tui/context.js +8 -0
  65. package/dist/src/tui/geometry.js +40 -0
  66. package/dist/src/tui/renderer.js +116 -0
  67. package/dist/src/tui/rows.js +247 -0
  68. package/dist/src/tui/scheduler.js +32 -0
  69. package/dist/src/tui/store.js +127 -0
  70. package/dist/src/tui/style.js +151 -0
  71. package/dist/src/tui/term.js +309 -0
  72. package/dist/src/tui/text.js +104 -0
  73. package/dist/src/tui/themes/index.js +15 -0
  74. package/dist/src/tui/themes/palettes.js +137 -0
  75. package/dist/src/tui/themes/types.js +1 -0
  76. package/dist/src/utils/diff.js +161 -0
  77. package/dist/src/utils/platform.js +71 -0
  78. package/dist/src/utils/signals.js +26 -0
  79. package/dist/src/version.js +4 -0
  80. package/package.json +71 -0
@@ -0,0 +1,13 @@
1
+ import { intToRgb } from './style.js';
2
+ /** 0xRRGGBB → '#rrggbb' for Ink's Chalk-compatible colour strings. */
3
+ export function rgbToHex(v) {
4
+ const [r, g, b] = intToRgb(v);
5
+ return `#${[r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('')}`;
6
+ }
7
+ /** Resolve a named theme style (e.g. 'accent') to a hex colour string. */
8
+ export function themeColor(theme, key) {
9
+ const style = theme.table.style(theme.styles[key]);
10
+ if (style?.fg != null)
11
+ return rgbToHex(style.fg);
12
+ return '#999999';
13
+ }
@@ -0,0 +1,46 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { useStore } from '../context.js';
4
+ import { themeColor } from '../colors.js';
5
+ const STATUS_DOT = {
6
+ idle: '○',
7
+ thinking: '◌',
8
+ streaming: '●',
9
+ tool: '⚙',
10
+ waiting: '…',
11
+ interrupted: '⊘',
12
+ error: '✗',
13
+ done: '✓',
14
+ };
15
+ function statusColor(theme, status) {
16
+ if (!theme)
17
+ return '#969cbc';
18
+ switch (status) {
19
+ case 'error':
20
+ return themeColor(theme, 'danger');
21
+ case 'done':
22
+ return themeColor(theme, 'success');
23
+ case 'thinking':
24
+ case 'streaming':
25
+ case 'tool':
26
+ return themeColor(theme, 'info');
27
+ case 'interrupted':
28
+ return themeColor(theme, 'warning');
29
+ default:
30
+ return themeColor(theme, 'muted');
31
+ }
32
+ }
33
+ /** Orchestration agent strip, toggled with /agents or Ctrl+T. */
34
+ export function AgentDock({ width, height }) {
35
+ const store = useStore();
36
+ const agents = [...store.agents.values()];
37
+ if (!store.dockOpen)
38
+ return null;
39
+ const cap = height ?? agents.length + 1;
40
+ if (cap <= 0)
41
+ return null;
42
+ const accent = store.theme ? themeColor(store.theme, 'accent') : '#7aa2f7';
43
+ const dim = store.theme ? themeColor(store.theme, 'dim') : '#565f89';
44
+ const shown = agents.slice(0, Math.max(0, cap - 1));
45
+ return (_jsxs(Box, { width: width, flexDirection: "column", children: [_jsx(Text, { color: accent, wrap: "truncate-end", children: "\u2508 agents \u2508" }), shown.length === 0 && cap >= 2 && _jsx(Text, { color: dim, wrap: "truncate-end", children: "no agents running" }), shown.map((a) => (_jsxs(Box, { children: [_jsx(Text, { color: statusColor(store.theme, a.status), children: STATUS_DOT[a.status] ?? '○' }), _jsx(Text, { color: dim, wrap: "truncate-end", children: ` ${a.name}` }), a.task && _jsx(Text, { color: dim, wrap: "truncate-end", children: ` · ${a.task}` })] }, a.id)))] }));
46
+ }
@@ -0,0 +1,35 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text, useWindowSize } from 'ink';
3
+ import { useStore } from '../context.js';
4
+ import { renderAnsi } from '../ansi.js';
5
+ import { renderInputArea } from './input.js';
6
+ import { composerHeight } from '../geometry.js';
7
+ import { buildTheme } from '../style.js';
8
+ /**
9
+ * The sticky prompt at the bottom of the screen. Reuses the existing
10
+ * `renderInputArea` logic (soft-wrap + block cursor baked into the ANSI).
11
+ * The OS cursor is deliberately left hidden (Ink default): the inverted
12
+ * block cell is the only cursor, so on mobile keyboards there is no second,
13
+ * misaligned caret fighting the real edit position.
14
+ */
15
+ export function Composer() {
16
+ const store = useStore();
17
+ const { columns } = useWindowSize();
18
+ const ask = store.pendingAsk;
19
+ const theme = store.theme ?? buildTheme('tokyonight');
20
+ let rows;
21
+ let cursorCol = 0;
22
+ if (ask) {
23
+ const dim = theme.table.sgr(theme.styles['dim']);
24
+ rows = [`${dim}permission prompt open — answer in the panel above${'\x1b[0m'}`];
25
+ cursorCol = 0;
26
+ }
27
+ else {
28
+ const height = composerHeight(store.input, columns);
29
+ const rendered = renderInputArea(store.input, theme, columns, height, store.streaming);
30
+ rows = rendered.rows;
31
+ cursorCol = rendered.cursorCol;
32
+ }
33
+ void cursorCol;
34
+ return (_jsx(Box, { flexDirection: "column", children: rows.map((r, i) => (_jsx(Box, { height: 1, children: _jsx(Text, { wrap: "truncate-end", children: renderAnsi(r, `composer-${i}`) }) }, i))) }));
35
+ }
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { useStore } from '../context.js';
4
+ import { themeColor } from '../colors.js';
5
+ function shortPath(cwd) {
6
+ const home = process.env.HOME;
7
+ if (home && cwd.startsWith(home))
8
+ return `~${cwd.slice(home.length) || '/'}`;
9
+ const parts = cwd.split('/').filter(Boolean);
10
+ if (parts.length <= 3)
11
+ return cwd || '/';
12
+ return `…/${parts.slice(-3).join('/')}`;
13
+ }
14
+ export function Header({ width }) {
15
+ const store = useStore();
16
+ const accent = store.theme ? themeColor(store.theme, 'accent') : '#7aa2f7';
17
+ const muted = store.theme ? themeColor(store.theme, 'muted') : '#565f89';
18
+ const dim = store.theme ? themeColor(store.theme, 'dim') : '#3b4281';
19
+ const border = store.theme ? themeColor(store.theme, 'border') : '#565f89';
20
+ const model = store.route ? `${store.route.provider}/${store.route.model}` : 'no route';
21
+ const dir = shortPath(store.cwd || process.cwd());
22
+ return (_jsxs(Box, { width: width, borderStyle: "single", borderColor: border, flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { height: 1, children: [_jsx(Text, { color: accent, bold: true, wrap: "truncate-end", children: '>_ orbit' }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: ` (v${store.version})` }), _jsx(Text, { color: muted, wrap: "truncate-end", children: ` model: ${model}` }), _jsx(Text, { color: dim, wrap: "truncate-end", children: ' /model' })] }), _jsx(Box, { height: 1, children: _jsx(Text, { color: dim, wrap: "truncate-end", children: `directory: ${dir}` }) })] }));
23
+ }
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { useStore } from '../context.js';
4
+ import { themeColor } from '../colors.js';
5
+ const MAX_VISIBLE = 8;
6
+ /** Interactive model selector shown above the composer (opened by /model). */
7
+ export function ModelPicker({ width }) {
8
+ const store = useStore();
9
+ const list = store.models;
10
+ const accent = store.theme ? themeColor(store.theme, 'accent') : '#7aa2f7';
11
+ const dim = store.theme ? themeColor(store.theme, 'dim') : '#565f89';
12
+ const muted = store.theme ? themeColor(store.theme, 'muted') : '#969cbc';
13
+ if (list.length === 0) {
14
+ return (_jsx(Box, { width: width, children: _jsx(Text, { color: dim, children: "no models available \u2014 configure a provider key first" }) }));
15
+ }
16
+ const idx = Math.min(Math.max(store.modelPicker.index, 0), list.length - 1);
17
+ const start = Math.max(0, Math.min(idx, list.length - MAX_VISIBLE));
18
+ const window = list.slice(start, start + MAX_VISIBLE);
19
+ return (_jsxs(Box, { width: width, flexDirection: "column", children: [start > 0 && _jsxs(Text, { color: muted, children: ["\u25B2 ", (start).toLocaleString(), " more\u2026"] }), window.map((m, i) => {
20
+ const at = start + i;
21
+ return (_jsxs(Box, { children: [_jsx(Text, { color: at === idx ? accent : dim, children: at === idx ? '❯ ' : ' ' }), _jsx(Text, { color: at === idx ? accent : dim, children: m })] }, m));
22
+ }), start + MAX_VISIBLE < list.length && _jsx(Text, { color: muted, children: "\u25BC more\u2026" }), _jsx(Text, { color: muted, children: "\u2191\u2193 select \u00B7 Enter choose \u00B7 Esc close" })] }));
23
+ }
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { ASK_OPTIONS } from '../store.js';
4
+ import { useStore } from '../context.js';
5
+ import { themeColor } from '../colors.js';
6
+ const PANEL_H = 5;
7
+ /**
8
+ * In-flow permission panel rendered above the separator/composer (not an
9
+ * absolute overlay), so it can never overlap streamed content or code fences.
10
+ * Keep PANEL_H in sync with ASK_H in geometry.ts.
11
+ */
12
+ export function PermissionModal({ cols }) {
13
+ const store = useStore();
14
+ const ask = store.pendingAsk;
15
+ const theme = store.theme;
16
+ if (!ask)
17
+ return null;
18
+ const accent = theme ? themeColor(theme, 'accent') : '#7aa2f7';
19
+ const warning = theme ? themeColor(theme, 'warning') : '#e0af68';
20
+ const dim = theme ? themeColor(theme, 'dim') : '#565f89';
21
+ const muted = theme ? themeColor(theme, 'muted') : '#969cbc';
22
+ const width = Math.max(20, cols);
23
+ const body = ask.prompt || 'Allow this action?';
24
+ return (_jsxs(Box, { width: width, height: PANEL_H, flexDirection: "column", borderStyle: "round", borderColor: ask.index === 0 ? accent : warning, paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: warning, children: '⚠ ' }), _jsx(Text, { bold: true, wrap: "truncate-end", children: body })] }), _jsx(Box, { children: ASK_OPTIONS.map((opt, i) => {
25
+ const active = i === ask.index;
26
+ const color = active ? (i === 0 ? accent : warning) : dim;
27
+ return (_jsxs(Box, { paddingRight: 4, children: [_jsx(Text, { color: color, children: active ? '❯ ' : ' ' }), _jsx(Text, { bold: active, color: color, children: opt })] }, opt));
28
+ }) }), _jsx(Box, { children: _jsx(Text, { color: muted, children: '↑↓ select · Enter choose · Esc/y/n answer' }) })] }));
29
+ }
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { useStore } from '../context.js';
4
+ import { themeColor } from '../colors.js';
5
+ /** Inline slash-command menu shown under the transcript while typing `/`. */
6
+ export function SlashMenu({ width }) {
7
+ const store = useStore();
8
+ const matches = store.slashMatches();
9
+ if (matches.length === 0)
10
+ return null;
11
+ const accent = store.theme ? themeColor(store.theme, 'accent') : '#7aa2f7';
12
+ const dim = store.theme ? themeColor(store.theme, 'dim') : '#565f89';
13
+ const muted = store.theme ? themeColor(store.theme, 'muted') : '#969cbc';
14
+ return (_jsxs(Box, { width: width, flexDirection: "column", children: [matches.map((m) => (_jsxs(Box, { children: [_jsx(Text, { color: accent, children: m.cmd }), _jsx(Text, { color: dim, children: ` ${m.desc}` })] }, m.cmd))), _jsx(Text, { color: muted, children: "Tab autocompletes \u00B7 Esc closes" })] }));
15
+ }
@@ -0,0 +1,27 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { useStore } from '../context.js';
4
+ import { themeColor } from '../colors.js';
5
+ function shortPath(cwd) {
6
+ const home = process.env.HOME;
7
+ if (home && cwd.startsWith(home))
8
+ return `~${cwd.slice(home.length) || '/'}`;
9
+ const parts = cwd.split('/').filter(Boolean);
10
+ if (parts.length <= 3)
11
+ return cwd || '/';
12
+ return `…/${parts.slice(-3).join('/')}`;
13
+ }
14
+ /** One line under the prompt: `model · path · state`. */
15
+ export function StatusLine({ width }) {
16
+ const store = useStore();
17
+ const model = store.route ? `${store.route.provider}/${store.route.model}` : 'no model';
18
+ const state = store.pendingAsk ? 'confirm' : store.streaming ? 'running' : 'idle';
19
+ const path = shortPath(store.cwd || process.cwd());
20
+ const dim = store.theme ? themeColor(store.theme, 'dim') : '#565f89';
21
+ const muted = store.theme ? themeColor(store.theme, 'muted') : '#969cbc';
22
+ const info = store.theme ? themeColor(store.theme, 'info') : '#7dcfff';
23
+ const body = `${model} · ${path} · `;
24
+ const stateColor = state === 'running' ? info : dim;
25
+ const right = ' /help';
26
+ return (_jsxs(Box, { width: width, justifyContent: "space-between", children: [_jsxs(Text, { color: muted, wrap: "truncate-end", children: [body, _jsx(Text, { color: stateColor, children: state })] }), _jsx(Text, { color: dim, wrap: "truncate-end", children: right })] }));
27
+ }
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { useStore } from '../context.js';
4
+ import { renderAnsi } from '../ansi.js';
5
+ /**
6
+ * Scrollable transcript window. Rows are pre-wrapped single terminal lines, so
7
+ * the visible window is a pure slice of the flat row array: PageUp moves the
8
+ * window back (`scrollOffset`), everything else sticks to the newest lines.
9
+ */
10
+ export function Transcript({ rows, viewport }) {
11
+ const store = useStore();
12
+ const height = Math.max(0, viewport);
13
+ const total = rows.length;
14
+ let start;
15
+ if (store.scrollOffset > 0) {
16
+ start = Math.max(0, Math.min(store.scrollOffset, Math.max(0, total - height)));
17
+ }
18
+ else {
19
+ start = Math.max(0, total - height);
20
+ }
21
+ const visible = rows.slice(start, start + height);
22
+ // Reserve the full region so layout height stays stable while scrolling.
23
+ if (height === 0)
24
+ return _jsx(Box, { flexGrow: 1 });
25
+ return (_jsx(Box, { flexGrow: 1, flexDirection: "column", overflow: "hidden", children: Array.from({ length: height }, (_, i) => {
26
+ const row = visible[i];
27
+ if (!row)
28
+ return _jsx(Box, { height: 1 }, `pad${i}`);
29
+ return (_jsx(Box, { height: 1, children: _jsx(Text, { wrap: "truncate-end", children: renderAnsi(row.text, row.id) }) }, row.id));
30
+ }) }));
31
+ }
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import { useStore } from '../context.js';
5
+ import { themeColor } from '../colors.js';
6
+ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
7
+ /** `• Working (Ns · esc to interrupt)` shown while the agent streams. */
8
+ export function WorkingStatus() {
9
+ const store = useStore();
10
+ const [frame, setFrame] = useState(0);
11
+ const [startAt] = useState(() => Date.now());
12
+ const [tick, setTick] = useState(0);
13
+ useEffect(() => {
14
+ if (!store.streaming)
15
+ return;
16
+ const id = setInterval(() => {
17
+ setFrame((f) => f + 1);
18
+ setTick((t) => t + 1);
19
+ }, 300);
20
+ return () => clearInterval(id);
21
+ }, [store.streaming]);
22
+ if (!store.streaming)
23
+ return null;
24
+ const seconds = Math.max(1, Math.floor((Date.now() - startAt) / 1000));
25
+ const spinner = FRAMES[frame % FRAMES.length];
26
+ void tick;
27
+ const muted = store.theme ? themeColor(store.theme, 'muted') : '#969cbc';
28
+ return (_jsx(Box, { justifyContent: "center", children: _jsx(Text, { color: muted, children: `${spinner} Working (${seconds}s · esc to interrupt)` }) }));
29
+ }
@@ -0,0 +1,246 @@
1
+ import stringWidth from 'string-width';
2
+ import { wrapText } from '../text.js';
3
+ const PROMPT = '❯ ';
4
+ const CONTINUE = ' ';
5
+ export class InputState {
6
+ lines = [''];
7
+ cursorLine = 0;
8
+ cursorCol = 0;
9
+ history = [];
10
+ historyIdx = -1;
11
+ pasteMode = false;
12
+ currentBuffer() {
13
+ return this.lines.join('\n');
14
+ }
15
+ clear() {
16
+ this.lines = [''];
17
+ this.cursorLine = 0;
18
+ this.cursorCol = 0;
19
+ this.historyIdx = -1;
20
+ }
21
+ pushHistory(text) {
22
+ if (text.trim().length > 0)
23
+ this.history.push(text);
24
+ this.historyIdx = this.history.length;
25
+ }
26
+ submitBuffer() {
27
+ const text = this.currentBuffer();
28
+ this.pushHistory(text);
29
+ this.clear();
30
+ return text;
31
+ }
32
+ insertChar(ch) {
33
+ const line = this.lines[this.cursorLine];
34
+ this.lines[this.cursorLine] = line.slice(0, this.cursorCol) + ch + line.slice(this.cursorCol);
35
+ this.cursorCol += ch.length;
36
+ }
37
+ insertNewline() {
38
+ const before = this.lines[this.cursorLine].slice(0, this.cursorCol);
39
+ const after = this.lines[this.cursorLine].slice(this.cursorCol);
40
+ this.lines[this.cursorLine] = before;
41
+ this.lines.splice(this.cursorLine + 1, 0, after);
42
+ this.cursorLine++;
43
+ this.cursorCol = 0;
44
+ }
45
+ backspace() {
46
+ if (this.cursorCol > 0) {
47
+ const line = this.lines[this.cursorLine];
48
+ this.lines[this.cursorLine] = line.slice(0, this.cursorCol - 1) + line.slice(this.cursorCol);
49
+ this.cursorCol--;
50
+ }
51
+ else if (this.cursorLine > 0) {
52
+ const prevLen = this.lines[this.cursorLine - 1].length;
53
+ this.lines[this.cursorLine - 1] += this.lines[this.cursorLine];
54
+ this.lines.splice(this.cursorLine, 1);
55
+ this.cursorLine--;
56
+ this.cursorCol = prevLen;
57
+ }
58
+ }
59
+ delete() {
60
+ const line = this.lines[this.cursorLine];
61
+ if (this.cursorCol < line.length) {
62
+ this.lines[this.cursorLine] = line.slice(0, this.cursorCol) + line.slice(this.cursorCol + 1);
63
+ }
64
+ else if (this.cursorLine < this.lines.length - 1) {
65
+ this.lines[this.cursorLine] += this.lines[this.cursorLine + 1];
66
+ this.lines.splice(this.cursorLine + 1, 1);
67
+ }
68
+ }
69
+ moveLeft() {
70
+ if (this.cursorCol > 0)
71
+ this.cursorCol--;
72
+ else if (this.cursorLine > 0) {
73
+ this.cursorLine--;
74
+ this.cursorCol = this.lines[this.cursorLine].length;
75
+ }
76
+ }
77
+ moveRight() {
78
+ const line = this.lines[this.cursorLine];
79
+ if (this.cursorCol < line.length)
80
+ this.cursorCol++;
81
+ else if (this.cursorLine < this.lines.length - 1) {
82
+ this.cursorLine++;
83
+ this.cursorCol = 0;
84
+ }
85
+ }
86
+ moveUp() {
87
+ const line = this.lines[this.cursorLine];
88
+ const target = Math.min(this.cursorCol, line.length);
89
+ // Move to the visual line above when possible (soft-wrap aware).
90
+ if (this.cursorLine > 0) {
91
+ this.cursorLine--;
92
+ this.cursorCol = Math.min(target, this.lines[this.cursorLine].length);
93
+ }
94
+ }
95
+ moveDown() {
96
+ const target = this.cursorCol;
97
+ if (this.cursorLine < this.lines.length - 1) {
98
+ this.cursorLine++;
99
+ this.cursorCol = Math.min(target, this.lines[this.cursorLine].length);
100
+ }
101
+ }
102
+ moveToStart() { this.cursorCol = 0; }
103
+ moveToEnd() { this.cursorCol = this.lines[this.cursorLine].length; }
104
+ moveHistoryUp() {
105
+ if (this.historyIdx <= 0)
106
+ return;
107
+ this.historyIdx--;
108
+ this.replaceWithHistory();
109
+ }
110
+ moveHistoryDown() {
111
+ if (this.historyIdx >= this.history.length - 1) {
112
+ this.historyIdx = this.history.length;
113
+ this.clear();
114
+ return;
115
+ }
116
+ this.historyIdx++;
117
+ this.replaceWithHistory();
118
+ }
119
+ replaceWithHistory() {
120
+ const entry = this.history[this.historyIdx] ?? '';
121
+ this.lines = entry.split('\n');
122
+ this.cursorLine = this.lines.length - 1;
123
+ this.cursorCol = this.lines[this.cursorLine].length;
124
+ }
125
+ deleteWordBack() {
126
+ const line = this.lines[this.cursorLine];
127
+ if (this.cursorCol === 0) {
128
+ this.backspace();
129
+ return;
130
+ }
131
+ let i = this.cursorCol - 1;
132
+ while (i > 0 && line[i] === ' ')
133
+ i--;
134
+ while (i > 0 && line[i - 1] !== ' ' && line[i - 1] !== '\n')
135
+ i--;
136
+ this.lines[this.cursorLine] = line.slice(0, i) + line.slice(this.cursorCol);
137
+ this.cursorCol = i;
138
+ }
139
+ deleteToEnd() {
140
+ const line = this.lines[this.cursorLine];
141
+ this.lines[this.cursorLine] = line.slice(0, this.cursorCol);
142
+ }
143
+ deleteToStart() {
144
+ const line = this.lines[this.cursorLine];
145
+ this.lines[this.cursorLine] = line.slice(this.cursorCol);
146
+ this.cursorCol = 0;
147
+ }
148
+ }
149
+ /**
150
+ * Render the input region at `width` × `height` (rows). Returns styled rows
151
+ * (subject to background-baking by composeRow) and cursor coordinates.
152
+ * The cursor row is always kept on screen.
153
+ */
154
+ export function renderInputArea(state, theme, width, height, streaming) {
155
+ const textSgr = theme.table.sgr(theme.styles['text']);
156
+ const dimSgr = theme.table.sgr(theme.styles['muted']);
157
+ const accentSgr = theme.table.sgr(theme.styles['accent']);
158
+ const invSgr = theme.table.sgr(theme.styles['selection']);
159
+ const reset = '\x1b[0m';
160
+ const contentW = Math.max(1, width - 2);
161
+ const visuals = [];
162
+ for (let li = 0; li < state.lines.length; li++) {
163
+ const raw = state.lines[li];
164
+ if (raw === '') {
165
+ visuals.push({ text: '', lineIdx: li, startChar: 0, endChar: 0 });
166
+ continue;
167
+ }
168
+ const wrapped = wrapText(raw, contentW);
169
+ let start = 0;
170
+ for (const piece of wrapped) {
171
+ visuals.push({ text: piece, lineIdx: li, startChar: start, endChar: start + piece.length });
172
+ start += piece.length;
173
+ }
174
+ }
175
+ // Cursor → visual row + column.
176
+ let cursorVis = 0;
177
+ let cursorTargetEnd = state.cursorLine; // prefer row containing cursor end
178
+ for (let i = 0; i < visuals.length; i++) {
179
+ const v = visuals[i];
180
+ if (v.lineIdx > state.cursorLine)
181
+ break;
182
+ const containsBefore = v.lineIdx < state.cursorLine ||
183
+ (v.lineIdx === state.cursorLine && state.cursorCol >= v.startChar);
184
+ if (containsBefore && v.lineIdx <= state.cursorLine) {
185
+ cursorVis = i;
186
+ // If the cursor sits exactly at a wrap boundary it belongs to this row.
187
+ if (v.lineIdx === state.cursorLine && state.cursorCol <= v.endChar) {
188
+ cursorTargetEnd = v.endChar;
189
+ }
190
+ if (state.cursorCol >= v.startChar && state.cursorCol <= v.endChar) {
191
+ cursorTargetEnd = v.endChar;
192
+ break;
193
+ }
194
+ if (state.cursorLine === v.lineIdx)
195
+ break;
196
+ }
197
+ }
198
+ // Cursor column within its visual row:
199
+ const cRow = visuals[cursorVis];
200
+ const cursorX = cRow
201
+ ? Math.min(state.cursorCol - cRow.startChar, stringWidth(cRow.text))
202
+ : 0;
203
+ // Window the visual rows so the cursor stays visible.
204
+ let windowStart = 0;
205
+ if (visuals.length > height) {
206
+ windowStart = Math.max(0, Math.min(cursorVis, visuals.length - height));
207
+ if (cursorVis < windowStart)
208
+ windowStart = cursorVis;
209
+ if (cursorVis >= windowStart + height)
210
+ windowStart = cursorVis - height + 1;
211
+ windowStart = Math.max(0, Math.min(windowStart, visuals.length - height));
212
+ }
213
+ const shown = visuals.slice(windowStart, windowStart + height);
214
+ const rows = [];
215
+ for (let i = 0; i < shown.length; i++) {
216
+ const v = shown[i];
217
+ const isFirst = i === 0 && windowStart === 0 && v.lineIdx === 0;
218
+ const prefix = isFirst ? `${accentSgr}${PROMPT}${reset}` : `${dimSgr}${CONTINUE}${reset}`;
219
+ const absCol = isFirst ? 2 : 2;
220
+ const rowIsCursor = windowStart + i === cursorVis;
221
+ let line;
222
+ if (rowIsCursor) {
223
+ const before = v.text.slice(0, cursorX);
224
+ const at = v.text[cursorX];
225
+ const after = v.text.slice(cursorX + 1);
226
+ line = `${textSgr}${before}${invSgr}${at ?? ' '}${reset}${textSgr}${after}${reset}`;
227
+ }
228
+ else {
229
+ line = `${textSgr}${v.text}${reset}`;
230
+ }
231
+ rows.push(`${prefix}${line}`);
232
+ void absCol;
233
+ }
234
+ const isEmpty = state.lines.length === 1 && state.lines[0] === '';
235
+ if (isEmpty && !streaming) {
236
+ rows[0] = `${accentSgr}${PROMPT}${reset}${dimSgr}type a message… · /help${reset}`;
237
+ }
238
+ else if (streaming) {
239
+ rows[0] = `${accentSgr}…${reset}${dimSgr} streaming — Esc to stop${reset}`;
240
+ }
241
+ // Fill remaining region height with blank rows.
242
+ while (rows.length < height)
243
+ rows.push('');
244
+ return { rows, cursorRow: windowStart === 0 ? cursorVis : Math.min(cursorVis - windowStart, height - 1), cursorCol: cursorX };
245
+ }
246
+ export { renderInputArea as renderInput }; // alias for old call-sites