Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
7b0a304
feat(cli): OpenTUI foundation modules — theme, a11y, clipboard, keys,…
chiga0 Aug 26, 2026
bd028bc
fix(cli): import originals instead of forking slash parser and dialog…
chiga0 Aug 27, 2026
a9ae4ba
fix(cli): address R1 review findings in OpenTUI foundation modules
chiga0 Aug 27, 2026
8e9a6c7
fix(cli): align OpenTUI command host with the memory-file-count rename
chiga0 Aug 28, 2026
81f13f4
feat(cli): OpenTUI migration live-session and input batch
chiga0 Aug 28, 2026
60af4db
fix(cli): address R2 review findings in OpenTUI foundation modules
chiga0 Aug 28, 2026
d09231c
fix(cli): harden kitty probe and screen-reader writer per maintainer …
chiga0 Aug 28, 2026
eb4be25
Merge remote-tracking branch 'fork/feat/tui-foundation-batch' into fe…
chiga0 Aug 28, 2026
e0c4f14
Merge branch 'main' into feat/tui-foundation-batch
chiga0 Aug 28, 2026
39cf0c1
Merge remote-tracking branch 'fork/feat/tui-foundation-batch' into fe…
chiga0 Aug 28, 2026
ff19f4f
fix(cli): address ytahdn independent review findings in OpenTUI found…
chiga0 Aug 28, 2026
ac1278f
Merge remote-tracking branch 'fork/feat/tui-foundation-batch' into fe…
chiga0 Aug 28, 2026
5319d84
Merge remote-tracking branch 'fork/feat/tui-foundation-batch' into fe…
chiga0 Aug 28, 2026
109921e
fix(cli): address R3 review findings in OpenTUI foundation modules
chiga0 Aug 28, 2026
1979cbd
Merge remote-tracking branch 'fork/feat/tui-foundation-batch' into fe…
chiga0 Aug 28, 2026
6e1e4d1
fix(cli): address R4 review findings in OpenTUI foundation modules
chiga0 Aug 28, 2026
46ec0b4
Merge remote-tracking branch 'fork/feat/tui-foundation-batch' into fe…
chiga0 Aug 28, 2026
b46565c
fix(cli): address #10383 R1 findings in foundation modules
chiga0 Aug 29, 2026
5c0e3ef
Merge remote-tracking branch 'fork/feat/tui-foundation-batch' into fe…
chiga0 Aug 29, 2026
ea1c60a
fix(cli): address #10383 R2 Critical findings in slash-dispatch
chiga0 Aug 29, 2026
c25947d
Merge remote-tracking branch 'fork/feat/tui-foundation-batch' into fe…
chiga0 Aug 29, 2026
53d657e
Merge remote-tracking branch 'origin/main' into feat/tui-foundation-b…
chiga0 Aug 30, 2026
65f9220
Merge commit '53d657e137' into feat/tui-live-session-batch
chiga0 Aug 30, 2026
656e996
fix(cli): address #10368 R2 review findings in live-session batch
chiga0 Aug 30, 2026
23655ee
fix(cli): declare cursorOffset on the FakeEditor test interface
chiga0 Aug 30, 2026
efbccbf
Merge remote-tracking branch 'origin/main' into feat/tui-foundation-b…
chiga0 Aug 31, 2026
51647ea
Merge commit 'efbccbf0db' into feat/tui-live-session-batch
chiga0 Aug 31, 2026
69c0e88
Merge remote-tracking branch 'origin/main' into feat/tui-live-session…
chiga0 Aug 31, 2026
00b4893
test(cli): stub listStartingRunIds in the session-switch fake registry
chiga0 Aug 31, 2026
68cf157
Merge remote-tracking branch 'origin/main' into feat/tui-live-session…
chiga0 Aug 31, 2026
b7fe5b9
Merge remote-tracking branch 'origin/main' into feat/tui-live-session…
chiga0 Aug 31, 2026
fd764e8
fix(opentui): address yiliang114 review findings (4 P2 + 1 P3)
chiga0 Aug 31, 2026
859ec33
fix(transcript): address R6 review — thinking latch, cancelled status…
chiga0 Aug 31, 2026
63a7f3c
fix(opentui): address review-pr bot R3 critical findings
chiga0 Aug 31, 2026
1b896e4
fix(opentui): address round-5 review findings R2-5 R3-2 R3-12 R4-1
chiga0 Sep 1, 2026
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
8 changes: 8 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@
"jsdom": "^26.1.0",
"pretty-format": "^30.0.2",
"react-dom": "^19.1.0",
"remend": "^1.3.1",
"supertest": "^7.2.2",
"typescript": "^5.3.3",
"vitest": "^3.1.1"
Expand Down
104 changes: 104 additions & 0 deletions packages/cli/src/ui/opentui/a11y-plain-text.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect } from 'vitest';
import { markdownToPlainText, stripAnsi } from './a11y-plain-text.js';

describe('stripAnsi', () => {
it('removes SGR color and attribute sequences', () => {
expect(stripAnsi('\x1b[31mred\x1b[0m')).toBe('red');
expect(stripAnsi('\x1b[1;33mbold yellow\x1b[0m')).toBe('bold yellow');
expect(stripAnsi('\x1b[38:5:208mext\x1b[0m')).toBe('ext');
});

it('removes cursor movement and erase sequences', () => {
expect(stripAnsi('\x1b[2K\x1b[1Atext\x1b[2J')).toBe('text');
expect(stripAnsi('\x1b[?25lhidden cursor\x1b[?25h')).toBe('hidden cursor');
});

it('removes OSC sequences with BEL or ST terminators', () => {
expect(stripAnsi('\x1b]8;;https://example.com\x07link\x1b]8;;\x07')).toBe(
'link',
);
expect(stripAnsi('\x1b]0;window title\x1b\\body')).toBe('body');
});

it('leaves plain text untouched', () => {
expect(stripAnsi('hello world')).toBe('hello world');
expect(stripAnsi('')).toBe('');
});
});

describe('markdownToPlainText', () => {
it('strips heading markers', () => {
expect(markdownToPlainText('# Title')).toBe('Title');
expect(markdownToPlainText('### Deep heading')).toBe('Deep heading');
});

it('strips emphasis and inline code markers', () => {
expect(markdownToPlainText('**bold** and *em* and _u_')).toBe(
'bold and em and u',
);
expect(markdownToPlainText('run `npm test` now')).toBe('run npm test now');
expect(markdownToPlainText('__strong__ ~~gone~~')).toBe('strong gone');
});

it('reduces links and images to their text', () => {
expect(markdownToPlainText('see [docs](https://x.dev) now')).toBe(
'see docs now',
);
expect(markdownToPlainText('logo ![alt text](img.png) end')).toBe(
'logo alt text end',
);
});

it('keeps fenced code bodies, dropping the fences', () => {
const md = ['```ts', 'const a = 1;', '```'].join('\n');
expect(markdownToPlainText(md)).toBe('const a = 1;');
});

it('drops blockquote prefixes and horizontal rules', () => {
expect(markdownToPlainText('> quoted line')).toBe('quoted line');
expect(markdownToPlainText('a\n---\nb')).toBe('a\n\nb');
});

it('keeps bullet markers and plain lines', () => {
expect(markdownToPlainText('- first\n- second')).toBe('- first\n- second');
expect(markdownToPlainText('just text')).toBe('just text');
});

it('leaves dunders and snake_case identifiers untouched', () => {
expect(markdownToPlainText('def __init__(self):')).toBe(
'def __init__(self):',
);
expect(markdownToPlainText('use snake_case_name here')).toBe(
'use snake_case_name here',
);
// Boundary-guarded underscore emphasis still works.
expect(markdownToPlainText('really _important_ now')).toBe(
'really important now',
);
});

it('keeps fence-like lines inside a block opened by the other character (R1-47/48)', () => {
// CommonMark: a fence only closes on the same character.
expect(markdownToPlainText('~~~\n```\nbody\n~~~\nafter')).toBe(
'```\nbody\nafter',
);
expect(markdownToPlainText('```\n~~~\nbody\n```\nafter')).toBe(
'~~~\nbody\nafter',
);
});

it('recognizes headings inside blockquotes (R1-1)', () => {
expect(markdownToPlainText('> # Title')).toBe('Title');
});

it('keeps code-span contents literal — no link/emphasis consumption (R1-1)', () => {
expect(markdownToPlainText('`[a](b)`')).toBe('[a](b)');
expect(markdownToPlainText('`**not bold**`')).toBe('**not bold**');
});
});
100 changes: 100 additions & 0 deletions packages/cli/src/ui/opentui/a11y-plain-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

/**
* Plain-text conversions for screen-reader parity. Ink's screen-reader path
* renders squashed text only (no styles, borders or backgrounds), so the
* OpenTUI equivalent needs ANSI-stripped, markdown-reduced text for anything
* it would otherwise draw with colors or structure.
*/

/**
* Matches ANSI escape sequences: CSI (colors, cursor movement), OSC
* (hyperlinks, title queries) with BEL/ST terminators, and two-character
* Fe sequences.
*/
const ANSI_ESCAPES =
// eslint-disable-next-line no-control-regex
/\x1b(?:\[[0-9;:?]*[ -/]*[@-~]|\][^\u0007\x1b]*(?:\u0007|\x1b\\)?|[@-Z\\-_])/g;

/** Strips all ANSI escape sequences, leaving the readable text. */
export function stripAnsi(text: string): string {
return text.replace(ANSI_ESCAPES, '');
}

/**
* Reduces markdown to the plain text a screen reader should announce:
* headings lose their hashes, fenced code keeps its body, emphasis markers
* disappear, links and images reduce to their text/alt, blockquote prefixes
* and horizontal rules are dropped. Bullet markers stay — they are readable
* content in the ink parity path too.
*/
export function markdownToPlainText(markdown: string): string {
const result: string[] = [];
// The fence character that opened the current code block, or null outside
// one. CommonMark: a fence only closes on the same character, so a
// fence-like line inside a block opened by the other character is body.
let fenceChar: '`' | '~' | null = null;

for (const rawLine of markdown.split('\n')) {
// Block-level passes run on the de-quoted view so headings and fences
// inside blockquotes are recognized; the prefix is not content.
const line = rawLine.replace(/^(?:\s*>\s?)+/, '');
const fenceMatch = /^\s*(```|~~~)/.exec(line);
if (fenceMatch) {
const char = fenceMatch[1]!.charAt(0) as '`' | '~';
if (fenceChar === null) {
fenceChar = char;
} else if (fenceChar === char) {
fenceChar = null;
} else {
result.push(line);
}
continue;
}
if (fenceChar !== null) {
result.push(line);
continue;
}

let text = line;
// Headings: "# Title" -> "Title".
text = text.replace(/^ {0,3}#{1,6}\s+/, '');
// Horizontal rules vanish in screen-reader output.
if (/^ {0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/.test(text)) {
result.push('');
continue;
}
// Extract code spans before the other inline passes: their contents are
// literal text and must not be consumed as links/emphasis markup.
const codeSpans: string[] = [];
text = text.replace(/`([^`]*)`/g, (_, span: string) => {
codeSpans.push(span);
return `\u0000${codeSpans.length - 1}\u0000`;
});
// Images -> alt text, links -> link text.
text = text.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1');
text = text.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1');
// Bold before italic so "**x**" is not eaten twice.
text = text.replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, '$1');
// Underscore emphasis only applies at word boundaries (CommonMark), so
// "__init__" and snake_case identifiers survive untouched.
text = text.replace(/(^|\s)__(?=\S)([\s\S]*?\S)__(?=\s|$)/g, '$1$2');
text = text.replace(/~~(?=\S)([\s\S]*?\S)~~/g, '$1');
text = text.replace(/\*(?=\S)([\s\S]*?\S)\*/g, '$1');
text = text.replace(/(^|\s)_(?=\S)([\s\S]*?\S)_(?=\s|$)/g, '$1$2');
// Restore the code-span contents last.
text = text.replace(
// eslint-disable-next-line no-control-regex -- NUL marks extracted code spans
/\u0000(\d+)\u0000/g,
(_, index: string) => codeSpans[Number(index)] ?? '',
);

result.push(text);
}

return result.join('\n');
}
Loading
Loading