Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,11 @@ Flags: `--model`, `--endpoint`, `--env-file`, `--theme`, `--thinking`,
`--verbose`, `--permissions ask|auto`, `--cwd`, `--channel`, `--max-turns`,
`--max-output-tokens`, `--seed`, `--no-hero`. `motif --help` has the full list.

If your terminal is configured to render East Asian Ambiguous characters as
two columns, launch with `MOTIF_AMBIGUOUS_WIDTH=2 motif` to match its cursor and
wrapping behavior. The default is one column. Set this in the shell environment;
it is a terminal preference, not an API configuration value in `.env`.

---

## Get an API key from Infron
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
},
"devDependencies": {
"@types/node": "^22.20.1",
"@xterm/headless": "6.0.0",
"esbuild": "^0.28.2",
"tsx": "^4.23.12",
"typescript": "^5.9.3",
Expand Down
5 changes: 4 additions & 1 deletion packages/tui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,8 @@
"description": "Terminal UI: typed cells, two-region streaming, local instruments",
"main": "./src/index.ts",
"exports": { ".": "./src/index.ts" },
"dependencies": { "@motifcode/core": "workspace:*" }
"dependencies": {
"@motifcode/core": "workspace:*",
"get-east-asian-width": "1.7.0"
}
}
40 changes: 24 additions & 16 deletions packages/tui/src/composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* is not text.
*/

import { displayWidth } from "./width.js";
import { displayWidth, expandTabs, truncateToWidth } from "./width.js";

export interface ComposerSnapshot {
text: string;
Expand Down Expand Up @@ -286,14 +286,16 @@ const DEFAULT_PROMPT = "❯ ";
* the next typed character would land.
*/
export function renderComposer(state: ComposerSnapshot, opts: ComposerRenderOptions): ComposerRender {
const prompt = opts.prompt ?? DEFAULT_PROMPT;
const width = Math.max(1, opts.width);
// Leave an editable column even when the prompt is wider than the viewport.
const prompt = truncateToWidth(opts.prompt ?? DEFAULT_PROMPT, width - 1, "");
const prefixWidth = displayWidth(prompt);
const room = Math.max(1, opts.width - prefixWidth);
const room = width - prefixWidth;
const continuation = " ".repeat(prefixWidth);

if (state.text === "") {
return {
rows: [{ prefix: prompt, body: opts.placeholder ?? "" }],
rows: [{ prefix: prompt, body: truncateToWidth(opts.placeholder ?? "", room) }],
cursorRow: 0,
cursorCol: prefixWidth,
placeholder: true,
Expand All @@ -314,24 +316,30 @@ export function renderComposer(state: ComposerSnapshot, opts: ComposerRenderOpti
};

for (const ch of state.text) {
if (index === state.cursor) {
cursorRow = rows.length;
cursorCol = prefixWidth + used;
}
if (ch === "\n") {
if (index === state.cursor) {
cursorRow = rows.length;
cursorCol = prefixWidth + used;
}
flush();
index += 1;
continue;
}
const w = displayWidth(ch);
if (used + w > room && used > 0) flush();
if (index === state.cursor && used === 0) {
// The cursor character itself wrapped; follow it.
cursorRow = rows.length;
cursorCol = prefixWidth;
// A tab expands only in the display. Its spaces may span several rows,
// while it remains one editable code point in the original draft.
let first = true;
for (const part of expandTabs(ch)) {
const shown = displayWidth(part) > room ? "?" : part;
const w = displayWidth(shown);
if (used + w > room && used > 0) flush();
if (index === state.cursor && first) {
cursorRow = rows.length;
cursorCol = prefixWidth + used;
}
body += shown;
used += w;
first = false;
}
body += ch;
used += w;
index += 1;
}
// The cursor after the last character.
Expand Down
71 changes: 50 additions & 21 deletions packages/tui/src/screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,16 +537,18 @@ export class Screen {
const width = this.columns();
const settled = this.settledLines();
const fresh = this.commits.take(settled.map((l) => l.text));
void fresh;
const previous = settled.slice(0, settled.length - fresh.length);
const footer = this.buildFooter(width);
const physical: string[] = [];
for (const l of settled) for (const t of wrapToWidth(l.text, width)) physical.push(this.colour({ ...l, text: t }));
for (const l of previous) for (const t of wrapToWidth(l.text, width)) physical.push(this.colour({ ...l, text: t }));
const keep = Math.max(0, this.rowCount() - footer.rows.length - 1);
this.withSync(() => {
this.footer = [];
this.cursorAt = null;
this.write(term.clearScreen + term.home);
for (const line of physical.slice(Math.max(0, physical.length - keep))) this.write(line + "\n");
// Commit newly settled text even when completion raced the resize.
for (const line of settled.slice(previous.length)) this.write(this.colour(line) + "\n");
this.writeFooter(footer, width);
});
}
Expand Down Expand Up @@ -594,13 +596,27 @@ export class Screen {
} else {
rows.push(...this.rows(this.statusOnly(), (t) => t));
}
return { rows, cursor };
// Leave one row below the footer for the parked cursor. Only the newest
// live rows are visible; omitted rows remain live and are never committed.
const limit = Math.max(0, this.rowCount() - 1);
let start = Math.max(0, rows.length - limit);
// A large draft/menu can itself exceed the viewport. Keep the editing
// cursor visible instead of showing only the end of the composer.
if (cursor && cursor.row < start) start = Math.max(0, cursor.row - Math.floor(limit / 2));
return {
rows: rows.slice(start, start + limit),
cursor: cursor && cursor.row >= start && cursor.row < start + limit
? { row: cursor.row - start, col: cursor.col } : null,
};
}

private writeFooter(footer: { rows: Row[]; cursor: { row: number; col: number } | null }, width: number): void {
const { rows, cursor } = footer;
if (this.composer) this.write(term.hideCursor);
for (const r of rows) this.write(r.text + "\n");
// Reserve room while it contains only cleared/settled content. Advancing
// after drawing a live row could scroll that row into permanent history.
this.write(term.lineStart + term.index.repeat(rows.length) + term.up(rows.length));
for (const r of rows) this.write(r.text + term.down(1) + term.lineStart);
this.footer = rows;
this.paintedWidth = width;
this.paintedHeight = this.rowCount();
Expand All @@ -610,7 +626,7 @@ export class Screen {
// The cursor sits on the empty line below the footer; lift it into the
// composer so the terminal's input method composes in the right place.
this.write(term.up(rows.length - cursor.row) + term.column(cursor.col) + term.showCursor);
}
} else if (rows.length === 0) this.write(term.showCursor);
}

/**
Expand All @@ -620,7 +636,15 @@ export class Screen {
* and nothing wraps. Returns where the cursor belongs among the rows.
*/
private composerRows(view: ComposerView, width: number): { rows: Row[]; cursorRow: number; cursorCol: number } {
const inner = Math.max(4, width - 4);
const boxed = width >= 8;
const inner = boxed ? width - 4 : Math.max(1, width);
// Box drawing is Ambiguous-width in Unicode. ASCII decorations preserve
// the four-column frame on terminals configured to render it wide.
const wideBox = displayWidth("─") > 1;
const side = wideBox ? "|" : "│";
const left = boxed ? `${side} ` : "";
const right = boxed ? ` ${side}` : "";
const edge = boxed ? 2 : 0;
// The box takes four columns: its edges and a space inside each.
const draft = view.secret
? { text: "•".repeat([...view.draft.text].length), cursor: view.draft.cursor }
Expand All @@ -631,10 +655,10 @@ export class Screen {
...(view.placeholder !== undefined && !view.secret ? { placeholder: view.placeholder } : {}),
});
const border = (l: string, r: string): Row => ({
text: paint(`${l}${"─".repeat(inner + 2)}${r}`, style.faint),
text: paint(`${wideBox ? "+" : l}${(wideBox ? "-" : "─").repeat(inner + 2)}${wideBox ? "+" : r}`, style.faint),
width: inner + 4,
});
const rows: Row[] = [border("╭", "╮")];
const rows: Row[] = boxed ? [border("╭", "╮")] : [];
if (view.confirm) {
const fit = (s: string): string => {
const t = truncateToWidth(s, inner);
Expand All @@ -651,12 +675,12 @@ export class Screen {
? paint(fit(l), style.accent)
: paint(fit(l), style.faint)
: fit(l);
rows.push({ text: `${paint("│ ", style.faint)}${painted}${paint(" │", style.faint)}`, width: inner + 4 });
rows.push({ text: `${paint(left, style.faint)}${painted}${paint(right, style.faint)}`, width });
}
rows.push(border("╰", "╯"));
if (boxed) rows.push(border("╰", "╯"));
// The cursor rests on the selected choice; there is nothing to type.
const selectedLine = view.confirm.choices.findIndex((c) => c.startsWith("❯"));
return { rows, cursorRow: 1 + firstChoice + Math.max(0, selectedLine), cursorCol: 2 };
return { rows, cursorRow: Number(boxed) + firstChoice + Math.max(0, selectedLine), cursorCol: edge };
}
let header = 0;
if (view.secret) {
Expand All @@ -671,7 +695,7 @@ export class Screen {
body.push({ text: "", title: false });
for (const { text, title } of body) {
const painted = title ? paint(fit(text), style.bold) : paint(fit(text), style.faint);
rows.push({ text: `${paint("│ ", style.faint)}${painted}${paint(" │", style.faint)}`, width: inner + 4 });
rows.push({ text: `${paint(left, style.faint)}${painted}${paint(right, style.faint)}`, width });
}
header = body.length;
}
Expand All @@ -680,16 +704,21 @@ export class Screen {
const plainWidth = displayWidth(row.prefix) + displayWidth(row.body);
const pad = " ".repeat(Math.max(0, inner - plainWidth));
rows.push({
text: `${paint("│ ", style.faint)}${paint(row.prefix, style.accent)}${body}${pad}${paint(" │", style.faint)}`,
width: inner + 4,
text: `${paint(left, style.faint)}${paint(row.prefix, style.accent)}${body}${pad}${paint(right, style.faint)}`,
width,
});
}
// +1 for the top border, plus any header rows; +2 for the box's left edge.
const cursorRow = 1 + header + render.cursorRow;
const cursorCol = Math.min(2 + render.cursorCol, Math.max(0, width - 1));
rows.push(border("╰", "╯"));
const cursorRow = Number(boxed) + header + render.cursorRow;
const cursorCol = Math.min(edge + render.cursorCol, Math.max(0, width - 1));
if (boxed) rows.push(border("╰", "╯"));
if (view.menu && view.menu.items.length > 0 && !view.secret) {
const menu = renderMenu(view.menu.items, view.menu.selected, { width, ...(view.menu.prefix ? { prefix: view.menu.prefix } : {}) });
// Reserve the composer, hint and parked cursor before sizing the menu.
// Its own window follows the selection even on a short terminal.
const maxRows = Math.max(0, Math.min(8, this.rowCount() - rows.length - 2));
const menu = maxRows > 0
? renderMenu(view.menu.items, view.menu.selected, { width, maxRows, ...(view.menu.prefix ? { prefix: view.menu.prefix } : {}) })
: { rows: [], selectedRow: -1 };
menu.rows.forEach((row, i) => {
const t = truncateToWidth(row, width);
rows.push({
Expand All @@ -704,7 +733,7 @@ export class Screen {
/**
* The line under the prompt: a hint on the left, the session's numbers on
* the right. The right side goes first when the two do not fit, and the
* hint is never truncated — "esc cl…" tells nobody what Esc does.
* hint is clipped only when it cannot fit by itself.
*/
private hintRows(width: number): Row[] {
const left = this.hint === "" ? "? for shortcuts" : this.hint;
Expand All @@ -718,7 +747,7 @@ export class Screen {
]
.filter(Boolean)
.join(paint(" · ", style.dim));
const leftText = ` ${truncateToWidth(left, Math.max(1, width - 2))}`;
const leftText = truncateToWidth(` ${left}`, width);
const gap = width - displayWidth(leftText) - displayWidth(rightPlain);
if (rightPlain === "" || gap < 2) return [{ text: paint(leftText, style.faint), width: displayWidth(leftText) }];
return [{ text: `${paint(leftText, style.faint)}${" ".repeat(gap)}${rightPainted}`, width }];
Expand Down Expand Up @@ -754,7 +783,7 @@ export class Screen {
this.write(term.up(total));
for (let i = 0; i < total; i++) {
this.write(term.clearLine + term.lineStart);
if (i < total - 1) this.write("\n");
if (i < total - 1) this.write(term.down(1));
}
this.write(term.up(total - 1));
this.footer = [];
Expand Down
2 changes: 2 additions & 0 deletions packages/tui/src/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ export const term = {
/** Top-left of the visible screen. */
home: `${ESC}H`,
lineStart: "\r",
/** Reserve a blank row before painting live content; may scroll at the bottom. */
index: "\x1bD",
up: (n: number) => (n > 0 ? `${ESC}${n}A` : ""),
down: (n: number) => (n > 0 ? `${ESC}${n}B` : ""),
/** Absolute column, 1-based in the terminal; this takes 0-based. */
Expand Down
37 changes: 30 additions & 7 deletions packages/tui/src/width.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,16 @@
* the model will get blamed for.
*/

/** Ranges from Unicode's East_Asian_Width = W or F. Coarse, and sufficient. */
import { eastAsianWidth } from "get-east-asian-width";

const TAB_SPACES = " ";

/** Display tabs as four spaces, independent of the terminal's tab stops. */
export function expandTabs(s: string): string {
return s.replace(/\t/g, TAB_SPACES);
}

/** Preserve the existing terminal/emoji widths alongside current Unicode data. */
const WIDE_RANGES: [number, number][] = [
[0x1100, 0x115f], // Hangul Jamo
[0x2e80, 0x303e], // CJK radicals, Kangxi
Expand Down Expand Up @@ -56,9 +65,11 @@ export function displayWidth(s: string): number {
let w = 0;
for (const ch of s) {
const cp = ch.codePointAt(0)!;
if (ch === "\t") { w += TAB_SPACES.length; continue; }
if (cp < 0x20 || cp === 0x7f) continue; // control characters print nothing
if (isZeroWidth(cp)) continue;
w += isWide(cp) ? 2 : 1;
// Terminal width preferences cannot be inferred reliably from locale.
w += isWide(cp) ? 2 : eastAsianWidth(cp, { ambiguousAsWide: process.env["MOTIF_AMBIGUOUS_WIDTH"] === "2" });
}
return w;
}
Expand All @@ -68,9 +79,13 @@ export function displayWidth(s: string): number {
* Never splits a code point, and never overshoots the budget.
*/
export function truncateToWidth(s: string, columns: number, ellipsis = "…"): string {
s = expandTabs(s);
ellipsis = expandTabs(ellipsis);
if (columns <= 0) return "";
if (displayWidth(s) <= columns) return s;
if (displayWidth(ellipsis) > columns) return truncateToWidth(ellipsis, columns, "");
const budget = columns - displayWidth(ellipsis);
if (budget <= 0) return ellipsis.slice(0, Math.max(0, columns));
if (budget <= 0) return ellipsis;
let out = "";
let w = 0;
for (const ch of s) {
Expand All @@ -84,9 +99,13 @@ export function truncateToWidth(s: string, columns: number, ellipsis = "…"): s

/** Keep the tail rather than the head — for a live ticker of the newest text. */
export function truncateEndToWidth(s: string, columns: number, ellipsis = "…"): string {
s = expandTabs(s);
ellipsis = expandTabs(ellipsis);
if (columns <= 0) return "";
if (displayWidth(s) <= columns) return s;
if (displayWidth(ellipsis) > columns) return truncateToWidth(ellipsis, columns, "");
const budget = columns - displayWidth(ellipsis);
if (budget <= 0) return ellipsis.slice(0, Math.max(0, columns));
if (budget <= 0) return ellipsis;
const chars = [...s];
let out = "";
let w = 0;
Expand All @@ -101,6 +120,7 @@ export function truncateEndToWidth(s: string, columns: number, ellipsis = "…")

/** Pad on the right to a column budget. */
export function padToWidth(s: string, columns: number, fill = " "): string {
s = expandTabs(s);
const gap = columns - displayWidth(s);
return gap > 0 ? s + fill.repeat(gap) : s;
}
Expand All @@ -118,14 +138,17 @@ export function wrapToWidth(s: string, columns: number): string[] {
const rows: string[] = [];
let row = "";
let used = 0;
for (const ch of s) {
const w = displayWidth(ch);
for (const ch of expandTabs(s)) {
// A two-column glyph cannot fit a one-column terminal. Keep the source
// intact and use an ASCII placeholder only in this displayed projection.
const shown = displayWidth(ch) > width ? "?" : ch;
const w = displayWidth(shown);
if (used + w > width && used > 0) {
rows.push(row);
row = "";
used = 0;
}
row += ch;
row += shown;
used += w;
}
rows.push(row);
Expand Down
Loading
Loading