sanook-cli 0.5.10 → 0.5.11

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/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.11
4
+
5
+ ### REPL layout stability
6
+
7
+ - **Transcript** — completed chat turns render in Ink `<Static>` so typing no longer redraws the whole history (input dock stays put).
8
+ - **Input** — Thai-safe gap cursor + single-row horizontal viewport (no 1↔2 line bounce).
9
+ - **Completions** — fixed-height slot for `/` and `@` completion overlay so the prompt does not jump when suggestions appear.
10
+ - **Regression guards** — source + behavior tests in `repl-layout-guard.test.ts`, `repl-input.test.tsx`, `overlay.test.tsx`.
11
+
3
12
  ## 0.5.10
4
13
 
5
14
  - Patch release (npm republish guard — use this version after 0.5.9 is already on the registry).
package/dist/ui/app.js CHANGED
@@ -2,7 +2,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
2
2
  import { useEffect, useState, useRef } from 'react';
3
3
  import { execFile } from 'node:child_process';
4
4
  import { promisify } from 'node:util';
5
- import { Box, Text, useApp, useInput, useStdout } from 'ink';
5
+ import { Box, Static, Text, useApp, useInput, useStdout } from 'ink';
6
6
  import { homedir } from 'node:os';
7
7
  import { BUILTIN_COMMANDS, HELP_TEXT, expandCustomCommand, loadCustomCommands, parseCommand, parseSlashInvocation, } from '../commands.js';
8
8
  import { runAgent } from '../loop.js';
@@ -31,13 +31,13 @@ import { expandMentions } from './mentions.js';
31
31
  import { BRAND } from '../brand.js';
32
32
  import { backgroundTaskRunningCount, listBackgroundTasks } from '../tools/task.js';
33
33
  import { Banner } from './banner.js';
34
- import { CompletionOverlay, FloatingOverlay, firstUserSummary } from './overlay.js';
34
+ import { CompletionOverlay, FloatingOverlay, firstUserSummary, shouldReserveCompletionSlot } from './overlay.js';
35
35
  import { clampQueueActiveIndex, compactPreview, getQueueWindow, queueActiveIndexAfterDelete } from './queue.js';
36
36
  import { MarkdownText, StreamingMarkdownText } from './markdown.js';
37
37
  import { SessionPanel } from './session-panel.js';
38
38
  import { getTranscriptWindow, transcriptScrollStep, transcriptWindowSize } from './transcript.js';
39
39
  import { footerStatus } from './status.js';
40
- import { inputViewport, graphemesOf, cursorGraphemeIndex, SCROLL_LEAD, SCROLL_TAIL } from './input-view.js';
40
+ import { inputViewport, graphemesOf, cursorInsertGraphemeIndex, SCROLL_LEAD, SCROLL_TAIL } from './input-view.js';
41
41
  import { PersonaOverlay } from './persona-wizard.js';
42
42
  import { thinkingPanelLines, snapshotThinking } from './thinking-panel.js';
43
43
  import { toolTrailLines, toolTrailHeader, toolTrailWidth, updateToolTrailOnEvent } from './tool-trail.js';
@@ -176,6 +176,9 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
176
176
  };
177
177
  const addTurn = (role, text, extras) => {
178
178
  setTranscriptScroll(0);
179
+ // Remount frozen transcript when history grows so scrollback styling (e.g. latest-only expanded
180
+ // tool diffs) stays correct. Keystrokes never call addTurn, so the input dock stays stable while typing.
181
+ setHistoryResetKey((key) => key + 1);
179
182
  setHistory((h) => [
180
183
  ...h,
181
184
  {
@@ -212,6 +215,7 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
212
215
  const pagerPageSize = Math.max(5, Math.min(18, (stdout?.rows ?? 24) - 10));
213
216
  const completion = !overlay && !busy ? completionForInput(editor.value, cwd) : { items: [], replaceFrom: 0 };
214
217
  const completions = completion.items;
218
+ const reserveCompletionSlot = shouldReserveCompletionSlot(editor.value, completions);
215
219
  const selectedCompletion = clampCompletionIndex(completionIndex, completions.length);
216
220
  useEffect(() => {
217
221
  let alive = true;
@@ -1208,20 +1212,25 @@ export function App({ initialModel, fallbackModel, budgetUsd, permissionMode = '
1208
1212
  const transcriptLimit = transcriptWindowSize(stdout?.rows);
1209
1213
  const transcriptView = getTranscriptWindow(history.length, transcriptLimit, transcriptScroll);
1210
1214
  const visibleHistory = history.slice(transcriptView.start, transcriptView.end);
1211
- return (_jsxs(Box, { flexDirection: "column", children: [history.length === 0 ? (_jsxs(_Fragment, { children: [_jsx(Banner, { columns: columns, model: model, mode: permissionMode === 'ask' ? 'ask' : 'auto', signals: bannerSignals }), _jsx(SessionPanel, { columns: columns, cwd: cwd, mcp: startupReadiness.mcp, model: model, mode: permissionMode === 'ask' ? 'ask' : 'auto', skills: startupReadiness.skills })] })) : null, transcriptView.showOlder ? (_jsxs(Text, { dimColor: true, children: ["\u2026 ", transcriptView.start, " older turns \u00B7 PgUp/Ctrl+U scroll \u00B7 PgDn/Ctrl+D newer"] })) : null, visibleHistory.map((turn) => (_jsx(TurnView, { columns: columns, isLatest: turn.id === latestTrailTurnId, thinkingMode: thinkingMode, toolTrailMode: toolTrailMode, turn: turn }, `${historyResetKey}-${turn.id}`))), transcriptView.showNewer ? (_jsxs(Text, { dimColor: true, children: ["\u2026 ", transcriptView.scrollFromBottom, " newer turns hidden \u00B7 PgDn/Ctrl+D to catch up"] })) : null, thinkingView.length ? _jsx(ThinkingView, { columns: columns, mode: thinkingMode, text: thinking }) : null, streaming ? (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(StreamingMarkdownText, { columns: columns, text: streaming }) })) : null, showToolTrail ? (_jsx(ToolTrailView, { columns: columns, items: toolTrail, mode: toolTrailMode })) : null, _jsx(FloatingOverlay, { columns: columns, overlay: overlay, pageSize: pagerPageSize }), _jsx(CompletionOverlay, { columns: columns, items: completions, selected: selectedCompletion }), queued.length ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: ["queued (", queued.length, ") \u00B7 \u2191\u2193 select \u00B7 Ctrl+X delete \u00B7 Esc clears"] }), queueWindow.showLead ? _jsx(Text, { dimColor: true, children: " \u2026" }) : null, queued.slice(queueWindow.start, queueWindow.end).map((q, i) => (_jsxs(Text, { color: queueWindow.start + i === activeQueueIndex ? 'yellow' : undefined, dimColor: queueWindow.start + i !== activeQueueIndex, children: [queueWindow.start + i === activeQueueIndex ? '›' : ' ', " ", queueWindow.start + i + 1, ".", ' ', compactPreview(q, Math.max(16, columns - 10))] }, `${queueWindow.start + i}-${q.slice(0, 16)}`))), queueWindow.showTail ? _jsxs(Text, { dimColor: true, children: [" \u2026and ", queued.length - queueWindow.end, " more"] }) : null] })) : null, approvalReq ? (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: "yellow", paddingX: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["\u0E2D\u0E19\u0E38\u0E21\u0E31\u0E15\u0E34\u0E23\u0E31\u0E19 ", approvalReq.tool, "?"] }), _jsx(Text, { dimColor: true, children: approvalReq.summary }), _jsx(Text, { dimColor: true, children: "y = \u0E23\u0E31\u0E19 \u00B7 n = \u0E1B\u0E0F\u0E34\u0E40\u0E2A\u0E18" })] })) : personaOpen ? (_jsx(PersonaOverlay, { onDone: (msg) => { setPersonaOpen(false); addTurn('system', msg); } })) : (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: busy ? 'gray' : 'blue', paddingX: 1, children: [_jsx(Text, { color: busy ? 'gray' : 'cyan', children: busy ? '· ' : '› ' }), _jsx(InputView, { value: editor.value, cursor: editor.cursor, busy: busy, agentStatus: agentStatus, toolTrail: toolTrail, columns: columns })] })), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: footerStatus({
1212
- branch: gitBranch,
1213
- backgroundTaskCount: bgTaskCount,
1214
- busy,
1215
- columns,
1216
- contextCompression,
1217
- contextTokens,
1218
- costHint,
1219
- cwd,
1220
- elapsedSeconds: busyElapsedSeconds,
1221
- model,
1222
- mode: permissionMode === 'ask' ? 'ask' : 'auto',
1223
- queuedCount: queued.length,
1224
- }) })] }));
1215
+ // REPL layout invariants see repl-layout-guard.test.ts (regression guards if this jumps while typing).
1216
+ // Pinned to latest: freeze completed turns in Ink <Static> so keystrokes only redraw input/footer
1217
+ // (readline / Claude Code pattern). When scrolled up, fall back to a windowed dynamic transcript.
1218
+ const pinnedToBottom = transcriptScroll === 0;
1219
+ const renderTurn = (turn) => (_jsx(TurnView, { columns: columns, isLatest: turn.id === latestTrailTurnId, thinkingMode: thinkingMode, toolTrailMode: toolTrailMode, turn: turn }, turn.id));
1220
+ return (_jsxs(Box, { flexDirection: "column", children: [history.length === 0 ? (_jsxs(_Fragment, { children: [_jsx(Banner, { columns: columns, model: model, mode: permissionMode === 'ask' ? 'ask' : 'auto', signals: bannerSignals }), _jsx(SessionPanel, { columns: columns, cwd: cwd, mcp: startupReadiness.mcp, model: model, mode: permissionMode === 'ask' ? 'ask' : 'auto', skills: startupReadiness.skills })] })) : null, pinnedToBottom ? (history.length ? (_jsx(Static, { items: history, children: (turn) => renderTurn(turn) }, historyResetKey)) : null) : (_jsxs(_Fragment, { children: [transcriptView.showOlder ? (_jsxs(Text, { dimColor: true, children: ["\u2026 ", transcriptView.start, " older turns \u00B7 PgUp/Ctrl+U scroll \u00B7 PgDn/Ctrl+D newer"] })) : null, visibleHistory.map((turn) => renderTurn(turn)), transcriptView.showNewer ? (_jsxs(Text, { dimColor: true, children: ["\u2026 ", transcriptView.scrollFromBottom, " newer turns hidden \u00B7 PgDn/Ctrl+D to catch up"] })) : null] })), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [thinkingView.length ? _jsx(ThinkingView, { columns: columns, mode: thinkingMode, text: thinking }) : null, streaming ? (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(StreamingMarkdownText, { columns: columns, text: streaming }) })) : null, showToolTrail ? (_jsx(ToolTrailView, { columns: columns, items: toolTrail, mode: toolTrailMode })) : null, _jsx(FloatingOverlay, { columns: columns, overlay: overlay, pageSize: pagerPageSize }), _jsx(CompletionOverlay, { columns: columns, items: completions, reserved: reserveCompletionSlot, selected: selectedCompletion }), queued.length ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: ["queued (", queued.length, ") \u00B7 \u2191\u2193 select \u00B7 Ctrl+X delete \u00B7 Esc clears"] }), queueWindow.showLead ? _jsx(Text, { dimColor: true, children: " \u2026" }) : null, queued.slice(queueWindow.start, queueWindow.end).map((q, i) => (_jsxs(Text, { color: queueWindow.start + i === activeQueueIndex ? 'yellow' : undefined, dimColor: queueWindow.start + i !== activeQueueIndex, children: [queueWindow.start + i === activeQueueIndex ? '›' : ' ', " ", queueWindow.start + i + 1, ".", ' ', compactPreview(q, Math.max(16, columns - 10))] }, `${queueWindow.start + i}-${q.slice(0, 16)}`))), queueWindow.showTail ? _jsxs(Text, { dimColor: true, children: [" \u2026and ", queued.length - queueWindow.end, " more"] }) : null] })) : null, approvalReq ? (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: "yellow", paddingX: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["\u0E2D\u0E19\u0E38\u0E21\u0E31\u0E15\u0E34\u0E23\u0E31\u0E19 ", approvalReq.tool, "?"] }), _jsx(Text, { dimColor: true, children: approvalReq.summary }), _jsx(Text, { dimColor: true, children: "y = \u0E23\u0E31\u0E19 \u00B7 n = \u0E1B\u0E0F\u0E34\u0E40\u0E2A\u0E18" })] })) : personaOpen ? (_jsx(PersonaOverlay, { onDone: (msg) => { setPersonaOpen(false); addTurn('system', msg); } })) : (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: busy ? 'gray' : 'blue', paddingX: 1, flexDirection: "row", flexShrink: 0, children: [_jsx(Text, { color: busy ? 'gray' : 'cyan', children: busy ? '· ' : '› ' }), _jsx(InputView, { value: editor.value, cursor: editor.cursor, busy: busy, agentStatus: agentStatus, toolTrail: toolTrail, columns: columns })] })), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: footerStatus({
1221
+ branch: gitBranch,
1222
+ backgroundTaskCount: bgTaskCount,
1223
+ busy,
1224
+ columns,
1225
+ contextCompression,
1226
+ contextTokens,
1227
+ costHint,
1228
+ cwd,
1229
+ elapsedSeconds: busyElapsedSeconds,
1230
+ model,
1231
+ mode: permissionMode === 'ask' ? 'ask' : 'auto',
1232
+ queuedCount: queued.length,
1233
+ }) })] })] }));
1225
1234
  }
1226
1235
  /** input ที่มี cursor (inverse) + placeholder — minimal; รับ input ได้แม้ busy (ต่อคิว) */
1227
1236
  function InputView({ value, cursor, busy, agentStatus, toolTrail, columns = 80, }) {
@@ -1239,12 +1248,11 @@ function InputView({ value, cursor, busy, agentStatus, toolTrail, columns = 80,
1239
1248
  }
1240
1249
  // multiline (กด Alt+Enter / ลงท้าย \) — สูงหลายบรรทัดตั้งใจอยู่แล้ว: render grapheme-cursor แบบ wrap ปกติ
1241
1250
  if (value.includes('\n')) {
1242
- const ci = cursorGraphemeIndex(value, cursor);
1251
+ const insertAt = cursorInsertGraphemeIndex(value, cursor);
1243
1252
  const graphemes = graphemesOf(value);
1244
- const before = graphemes.slice(0, ci).join('');
1245
- const at = ci < graphemes.length ? graphemes[ci] : ' ';
1246
- const after = ci < graphemes.length ? graphemes.slice(ci + 1).join('') : '';
1247
- return (_jsxs(Text, { children: [before, _jsx(Text, { inverse: true, children: at }), after, busy ? _jsxs(Text, { dimColor: true, children: [' ', "(\u23CE \u0E15\u0E48\u0E2D\u0E04\u0E34\u0E27)"] }) : null] }));
1253
+ const before = graphemes.slice(0, insertAt).join('');
1254
+ const after = graphemes.slice(insertAt).join('');
1255
+ return (_jsxs(Text, { children: [before, _jsx(Text, { inverse: true, children: ' ' }), after, busy ? _jsxs(Text, { dimColor: true, children: [' ', "(\u23CE \u0E15\u0E48\u0E2D\u0E04\u0E34\u0E27)"] }) : null] }));
1248
1256
  }
1249
1257
  // บรรทัดเดียว: viewport กว้างคงที่ (เลื่อนแนวนอนแทน wrap) → กล่อง input สูง 1 บรรทัดเสมอ ไม่เด้งตอนพิมพ์ไทย
1250
1258
  // เผื่อ overhead: border(2) + paddingX(2) + prefix "› "(2) + ช่อง cursor/suffix ~2
@@ -1,13 +1,13 @@
1
1
  import stringWidth from 'string-width';
2
- import { graphemeBoundaries } from './useEditor.js';
2
+ import { clampCursorToGrapheme, graphemeBoundaries } from './useEditor.js';
3
3
  // ────────────────────────────────────────────────────────────────────────────
4
4
  // Stable, Thai-safe rendering of the REPL input line.
5
+ // Regression guards: repl-layout-guard.test.ts + input-view.test.ts (width + gap cursor).
5
6
  //
6
7
  // Two bugs this fixes (เทียบกับ CLI เจ้าอื่นที่ "นิ่ง"):
7
- // 1) cursor split a grapheme cluster — the old code did value.slice(cursor, cursor+1),
8
- // which on Thai cuts a base char away from its combining vowel/tone mark (สระ/วรรณยุกต์
9
- // เป็น zero-width). The orphaned mark then renders on its own cell → "อักษรห่างเกินไป".
10
- // Fix: the cursor highlights a WHOLE grapheme cluster (base + all its marks).
8
+ // 1) block cursor on Thai textinverting the grapheme under the caret paints a solid
9
+ // cell over the base char + combining marks and looks like the cursor "covers" the letter.
10
+ // Fix: gap cursor render an inverse space BETWEEN clusters, never on top of a letter.
11
11
  // 2) the line bounced between 1 and 2 rows while typing — a wrapping <Text> grows the box
12
12
  // vertically the moment content crosses the right edge, shoving the footer down on every
13
13
  // keystroke. Fix: a fixed-width horizontal viewport (readline-style) so the input box is
@@ -26,17 +26,20 @@ export function graphemesOf(value) {
26
26
  out.push(value.slice(bounds[i], bounds[i + 1]));
27
27
  return out;
28
28
  }
29
- /** grapheme-cluster index that a code-unit cursor sits at (0..graphemeCount) */
30
- export function cursorGraphemeIndex(value, cursor) {
29
+ /** insert position between grapheme clusters (0 = before first char, n = after last) */
30
+ export function cursorInsertGraphemeIndex(value, cursor) {
31
+ const clamped = clampCursorToGrapheme(value, cursor);
31
32
  const bounds = graphemeBoundaries(value);
32
- let index = 0;
33
33
  for (let i = 0; i < bounds.length; i += 1) {
34
- if (bounds[i] <= cursor)
35
- index = i;
36
- else
37
- break;
34
+ if (bounds[i] === clamped)
35
+ return i;
38
36
  }
39
- return index;
37
+ return bounds.length - 1;
38
+ }
39
+ /** @deprecated use cursorInsertGraphemeIndex — kept for callers that need cluster index */
40
+ export function cursorGraphemeIndex(value, cursor) {
41
+ const insert = cursorInsertGraphemeIndex(value, cursor);
42
+ return insert >= graphemesOf(value).length ? Math.max(0, insert - 1) : insert;
40
43
  }
41
44
  /** display width of one grapheme, never less than 1 cell (so the cursor always has a cell) */
42
45
  function cellWidth(grapheme) {
@@ -50,27 +53,29 @@ function cellWidth(grapheme) {
50
53
  export function inputViewport(value, cursor, width) {
51
54
  const w = Math.max(4, Math.floor(width));
52
55
  const graphemes = graphemesOf(value);
53
- const ci = cursorGraphemeIndex(value, cursor);
54
- // a trailing sentinel cell so a cursor parked at end-of-line still has somewhere to sit
55
- const units = [...graphemes.map((g) => ({ text: g, width: cellWidth(g) })), { text: ' ', width: 1 }];
56
- const cursorUnit = Math.min(ci, units.length - 1);
56
+ const insertAt = cursorInsertGraphemeIndex(value, cursor);
57
+ const units = [];
58
+ for (let i = 0; i < insertAt; i += 1) {
59
+ units.push({ text: graphemes[i], width: cellWidth(graphemes[i]), isCursor: false });
60
+ }
61
+ units.push({ text: ' ', width: 1, isCursor: true });
62
+ for (let i = insertAt; i < graphemes.length; i += 1) {
63
+ units.push({ text: graphemes[i], width: cellWidth(graphemes[i]), isCursor: false });
64
+ }
65
+ const cursorUnit = insertAt;
57
66
  const totalWidth = units.reduce((sum, u) => sum + u.width, 0);
58
67
  if (totalWidth <= w) {
59
68
  return {
60
69
  lead: false,
61
- before: graphemes.slice(0, cursorUnit).join(''),
62
- at: cursorUnit < graphemes.length ? graphemes[cursorUnit] : ' ',
63
- after: cursorUnit < graphemes.length ? graphemes.slice(cursorUnit + 1).join('') : '',
70
+ before: graphemes.slice(0, insertAt).join(''),
71
+ at: ' ',
72
+ after: graphemes.slice(insertAt).join(''),
64
73
  tail: false,
65
74
  };
66
75
  }
67
- // overflow → slide a window that always contains the cursor unit; reserve 1 cell for each
68
- // truncation marker that will actually be shown.
69
76
  let start = cursorUnit;
70
77
  let end = cursorUnit + 1;
71
78
  let used = units[cursorUnit].width;
72
- // extend right first (so typing at end keeps the tail in view), then backfill left context.
73
- // the marker reservations (start>0 ⇒ ‹, end<len ⇒ ›) are folded into each fit check.
74
79
  while (end < units.length) {
75
80
  const next = units[end].width;
76
81
  if (used + next + (start > 0 ? 1 : 0) + (end + 1 < units.length ? 1 : 0) <= w) {
@@ -91,13 +96,13 @@ export function inputViewport(value, cursor, width) {
91
96
  }
92
97
  const slice = (from, to) => units
93
98
  .slice(from, to)
99
+ .filter((u) => !u.isCursor)
94
100
  .map((u) => u.text)
95
101
  .join('');
96
- const atUnit = units[cursorUnit];
97
102
  return {
98
103
  lead: start > 0,
99
104
  before: slice(start, cursorUnit),
100
- at: cursorUnit === units.length - 1 ? ' ' : atUnit.text,
105
+ at: ' ',
101
106
  after: slice(cursorUnit + 1, end),
102
107
  tail: end < units.length,
103
108
  };
@@ -11,6 +11,22 @@ const TASK_WINDOW = 10;
11
11
  const TOOL_WINDOW = 10;
12
12
  const DEFAULT_PAGER_PAGE_SIZE = 12;
13
13
  const COMPLETION_WINDOW = 8;
14
+ /** Item rows + footer hint row — fixed slot height so / and @ completions do not bounce the input dock. */
15
+ export const COMPLETION_OVERLAY_SLOT_LINES = COMPLETION_WINDOW + 1;
16
+ /** Terminal rows reserved above the input while slash/@ completion is active (content + border). */
17
+ export const COMPLETION_OVERLAY_RESERVED_ROWS = COMPLETION_OVERLAY_SLOT_LINES + 2;
18
+ export function shouldReserveCompletionSlot(input, items) {
19
+ if (items.length > 0)
20
+ return true;
21
+ return input.startsWith('/') || input.includes('@');
22
+ }
23
+ export function completionOverlaySlotLines(items, selected, columns) {
24
+ const active = completionOverlayLines(items, selected, columns);
25
+ const slot = active.length ? [...active] : [];
26
+ while (slot.length < COMPLETION_OVERLAY_SLOT_LINES)
27
+ slot.push('');
28
+ return slot.slice(0, COMPLETION_OVERLAY_SLOT_LINES);
29
+ }
14
30
  function OverlayBox({ children, columns }) {
15
31
  const width = overlayWidth(columns);
16
32
  return (_jsx(Box, { borderStyle: "double", borderColor: "cyan", flexDirection: "column", marginBottom: 1, paddingX: 1, width: width, children: children }));
@@ -41,16 +57,17 @@ export function completionOverlayLines(items, selected, columns) {
41
57
  lines.push('↑↓ select · Tab/Enter complete');
42
58
  return lines;
43
59
  }
44
- export function CompletionOverlay({ columns, items, selected }) {
60
+ export function CompletionOverlay({ columns, items, selected, reserved = false }) {
61
+ if (!reserved)
62
+ return null;
45
63
  const width = Math.max(28, Math.min(Math.max(34, columns - 6), MAX_OVERLAY_COLUMNS));
46
64
  const innerWidth = Math.max(1, width - 4);
47
- const lines = completionOverlayLines(items, selected, columns);
48
- if (!lines.length)
49
- return null;
50
- return (_jsx(Box, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", marginBottom: 1, paddingX: 1, width: width, children: lines.map((line, index) => {
51
- const isActive = line.startsWith('>');
52
- return (_jsx(Text, { color: isActive ? 'green' : undefined, dimColor: !isActive, inverse: isActive, wrap: "truncate-end", children: clip(line, innerWidth) }, `${index}-${line}`));
53
- }) }));
65
+ const lines = completionOverlaySlotLines(items, selected, columns);
66
+ const visible = items.length > 0;
67
+ return (_jsx(Box, { flexDirection: "column", height: COMPLETION_OVERLAY_RESERVED_ROWS, justifyContent: "flex-end", marginBottom: 1, width: width, children: visible ? (_jsx(Box, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 1, children: lines.map((line, index) => {
68
+ const isActive = line.startsWith('>');
69
+ return (_jsx(Text, { color: isActive ? 'green' : undefined, dimColor: !isActive, inverse: isActive, wrap: "truncate-end", children: clip(line || ' ', innerWidth) }, `${index}-${line || 'blank'}`));
70
+ }) })) : null }));
54
71
  }
55
72
  export function hotkeyOverlayLines(columns) {
56
73
  const width = overlayWidth(columns);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sanook-cli",
3
- "version": "0.5.10",
3
+ "version": "0.5.11",
4
4
  "description": "A terminal AI coding agent — BYOK, 9 providers, MCP, cron gateway, skills, and git awareness. Built from scratch in TypeScript.",
5
5
  "type": "module",
6
6
  "bin": {