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 +9 -0
- package/dist/ui/app.js +30 -22
- package/dist/ui/input-view.js +31 -26
- package/dist/ui/overlay.js +25 -8
- package/package.json +1 -1
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,
|
|
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
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
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
|
|
1251
|
+
const insertAt = cursorInsertGraphemeIndex(value, cursor);
|
|
1243
1252
|
const graphemes = graphemesOf(value);
|
|
1244
|
-
const before = graphemes.slice(0,
|
|
1245
|
-
const
|
|
1246
|
-
|
|
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
|
package/dist/ui/input-view.js
CHANGED
|
@@ -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
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// Fix: the cursor highlights a WHOLE grapheme cluster (base + all its marks).
|
|
8
|
+
// 1) block cursor on Thai text — inverting 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
|
|
30
|
-
export function
|
|
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]
|
|
35
|
-
|
|
36
|
-
else
|
|
37
|
-
break;
|
|
34
|
+
if (bounds[i] === clamped)
|
|
35
|
+
return i;
|
|
38
36
|
}
|
|
39
|
-
return
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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,
|
|
62
|
-
at:
|
|
63
|
-
after:
|
|
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:
|
|
105
|
+
at: ' ',
|
|
101
106
|
after: slice(cursorUnit + 1, end),
|
|
102
107
|
tail: end < units.length,
|
|
103
108
|
};
|
package/dist/ui/overlay.js
CHANGED
|
@@ -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 =
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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