diff --git a/docs/ai/design/2026-08-15-feature-agent-console-markdown-preview.md b/docs/ai/design/2026-08-15-feature-agent-console-markdown-preview.md new file mode 100644 index 00000000..0649d42c --- /dev/null +++ b/docs/ai/design/2026-08-15-feature-agent-console-markdown-preview.md @@ -0,0 +1,88 @@ +--- +phase: design +title: Agent Console Markdown Preview Design +description: A safe Markdown-token-to-styled-terminal-row pipeline with memoized active-preview layout and visible-row slicing. +--- + +# Agent Console Markdown Preview Design + +## Architecture Overview + +```mermaid +flowchart LR + M[20-message conversation tail] --> S[Sanitize terminal controls] + S --> P[Parse supported Markdown tokens] + P --> L[Lay out styled physical rows at preview width] + L --> C[Component-local memoized rows] + C --> V[Clamp and slice viewport by scrollOffset] + V --> I[Render visible Ink rows only] +``` + +`PreviewPane` retains the current state selection and scroll-offset adjustment. Message content and the explicit content width are the only inputs to the memoized parse/layout stage. `scrollOffset` is consumed only by the cheap viewport stage. `PreviewSection` passes the current right-pane width; narrow mode remains unchanged because the preview is not newly exposed there. + +## Data Models + +```ts +interface PreviewSpan { + text: string; + bold?: boolean; + italic?: boolean; + dimColor?: boolean; + color?: string; + backgroundColor?: string; +} + +interface PreviewViewportRow { + kind: 'header' | 'content' | 'separator' | 'indicator'; + spans: PreviewSpan[]; + role: ConversationMessage['role'] | null; + timestamp?: string; +} +``` + +- Header/separator/indicator rows preserve existing semantics. +- Content rows contain already wrapped styled spans. Their plain display width never exceeds the supplied content width except where a single terminal grapheme itself cannot be split. +- Layout data exists only inside the mounted active `PreviewPane` memo for the current message array and width. + +## API Design + +- `PreviewSectionProps.contentWidth: number` carries `max(1, inputInnerWidth - 2)` from `ConsoleApp`: panel borders/padding are already removed by `inputInnerWidth`, and two columns are reserved for the existing message-body indent. +- `PreviewPaneProps.contentWidth?: number` defaults conservatively for direct tests/callers. +- Pure helpers parse and lay out messages, build a viewport from stable rows, and render row spans. +- No external API, persistence, schema, polling, conversation-tail, or focus-key contract changes. + +## Component Breakdown + +- `PreviewPane.tsx`: state preservation, message-row memoization, viewport slicing, and visible-row Ink rendering. +- `markdownPreview.ts` (or equivalently small render module): sanitization, Marked token traversal, inline style flattening, safe fallbacks, terminal-width wrapping, and message row construction. +- `PreviewSection.tsx` / `ConsoleApp.tsx`: width plumbing only. +- Focused test modules: pure Markdown/layout coverage plus `PreviewPane` render and offset-only rerender regression coverage. + +## Rendering Rules + +- Headings: bold accent text; heading markers are not displayed. +- Bold/emphasis: Ink bold/italic attributes. +- Inline code: distinct existing palette color; fenced code uses a dim fence label when present and indented literal lines, without highlighting. +- Lists: `•` or parser-provided ordered numbers with hanging indentation. +- Blockquotes: dim `│ ` prefix with recursively rendered body. +- Links: styled label followed by a dim plain ` (URL)` when the destination differs from the label. +- Raw HTML: sanitized literal source text, never passed to an HTML/ANSI interpreter. +- Images: safe text fallback containing alt text and destination, never fetched or rendered. +- Unsupported/malformed constructs: sanitized literal or parser text fallback; parsing exceptions fall back to sanitized source lines. +- Control handling: strip ANSI/OSC/C0 control sequences except source newlines/tabs before parsing and layout; rendered spans never contain terminal control characters. + +## Design Decisions + +- Add `marked` as a direct CLI dependency because it already has repository precedent, exposes block/inline tokens, and avoids a bespoke grammar. +- Add or directly declare a terminal display-width utility if required; do not rely on an undeclared transitive dependency. +- Keep parsing and layout in one `useMemo([messages, width])`. This is the smallest cache satisfying offset-only optimization and is bounded by the active 20-message tail/current width. +- Slice the viewport before mapping rows to Ink elements. No binary search or transcript virtualization is needed for twenty messages. +- Preserve numeric bottom-relative offsets and current appended-row adjustment. Frozen updates and semantic anchors remain explicit non-goals. + +## Non-Functional Requirements + +- **Performance:** offset-only rerenders reuse stable laid-out rows; viewport work is O(visible rows) for React element creation and O(1) bounds plus array slice for selection. +- **Memory:** one active message array's parsed/laid-out result at one width; released when messages, width, selection, or component lifetime changes. +- **Security:** no raw HTML, image retrieval, OSC hyperlinks, ANSI, or terminal controls; URLs remain inert displayed text. +- **Reliability:** parser exceptions and malformed tokens fall back to sanitized text without affecting preview state handling. +- **Compatibility:** preserve current props with optional width defaults where useful, and minimize overlap with open cached-preview metadata work. diff --git a/docs/ai/implementation/2026-08-15-feature-agent-console-markdown-preview.md b/docs/ai/implementation/2026-08-15-feature-agent-console-markdown-preview.md new file mode 100644 index 00000000..1cf00399 --- /dev/null +++ b/docs/ai/implementation/2026-08-15-feature-agent-console-markdown-preview.md @@ -0,0 +1,104 @@ +--- +phase: implementation +title: Agent Console Markdown Preview Implementation +description: Current implementation structure, decisions, integration points, safety, and performance notes. +--- + +# Agent Console Markdown Preview Implementation + +## Development Setup + +- Worktree: `.worktrees/feature-agent-console-markdown-preview` +- Branch: `feature-agent-console-markdown-preview` +- Bootstrap: `npm ci` (completed; Husky could not update the shared sandboxed git config, while package installation completed). +- Runtime dependencies added: `marked` aligned with the repository's existing channel-connector major version and `string-width` for terminal display columns. + +## Code Structure + +- `packages/cli/src/tui/console/render/markdownPreview.ts`: pure Markdown token-to-styled-span/row renderer (in progress). +- `packages/cli/src/__tests__/tui/console/render/markdownPreview.test.ts`: focused TDD specifications. +- `PreviewPane.tsx`: memoized Markdown row construction, viewport slicing, and visible styled-span rendering. +- `PreviewSection.tsx` / `ConsoleApp.tsx`: explicit preview content-width plumbing from terminal layout. + +## Implementation Notes + +### Completed + +- Tasks 1.1–1.2: Marked lexer traversal for headings, paragraphs/text, bold, emphasis, inline code, fenced code, ordered/unordered lists, blockquotes, and links. +- Code fences produce optional dim language labels and literal warning-colored, indented lines without highlighting. +- Lists use deterministic bullets/numbers and continuation indentation; blockquotes use a dim terminal bar; links display styled labels plus inert plain destinations. +- Styled spans reuse existing `TUI_COLORS`; no ANSI or terminal hyperlink output is generated. +- TDD evidence: five focused behaviors each failed before their minimum implementation and now pass together. +- Tasks 2.1–2.2: raw HTML/table source stays inert, images become dim label/destination fallbacks, VT/C0 controls are removed before parsing, empty content retains one row, and styled rows wrap by grapheme display width with hanging prefixes. +- Markdown soft/source line breaks are split before viewport accounting so one row never hides multiple physical terminal lines. +- Current focused evidence: 11 tests passed. + +### In progress / next + +- Formal implementation alignment, coverage, broader regression checks, and review. + +### Patterns & Best Practices + +- Pure token traversal and immutable span objects. +- Unsupported block tokens retain sanitized raw lines; images have an explicit inert fallback. +- Production behavior is added only after its focused test fails for the intended reason. + +## Integration Points + +- Marked is a direct CLI runtime dependency; no channel-connector internals are imported. +- Conversation acquisition, polling, 20-message tailing, focus routing, and channel state are unchanged. +- `computeLayout` derives `previewContentWidth` from `inputInnerWidth - 2`; `PreviewSection` passes it directly to `PreviewPane`. + +## Error Handling + +- Parser errors fall back to sanitized literal rows; Marked also safely tokenizes incomplete Markdown without executing content. + +## Performance Considerations + +- The pure renderer wraps by Unicode grapheme display width, prefers word boundaries, and repeats or hangs list/quote/code prefixes where space permits. +- `PreviewPane` memoizes complete parse/layout rows only on `[messages, contentWidth]`; scroll offset is excluded. +- The viewport is computed from stable rows and sliced before Ink elements are mapped. +- No global cache exists; memory is bounded by the mounted active preview's current 20-message array and width. + +## Security Notes + +- Current output is data-only span objects. +- ANSI/OSC/VT controls and unsafe C0/C1 characters are removed before parsing; raw HTML remains literal, images are never fetched, and links remain inert display text. + +## Design Deviations + +None known. The implementation follows the selected direct-parser/pure-row architecture without adding excluded cache, anchoring, history, toggle, highlighting, or virtualization scope. + +## Changed Files + +- `packages/cli/package.json`, `package-lock.json`: direct `marked` and `string-width` runtime dependencies. +- `packages/cli/src/tui/console/render/markdownPreview.ts`: pure safe parser, styling, fallback, and display-width layout. +- `packages/cli/src/tui/console/PreviewPane.tsx`: styled row model, memoized parse/layout, viewport-first rendering. +- `packages/cli/src/tui/console/PreviewSection.tsx`, `ConsoleApp.tsx`: explicit content-width flow. +- `packages/cli/src/__tests__/tui/console/render/markdownPreview.test.ts`: syntax, safety, malformed, Unicode, and wrapping tests. +- `packages/cli/src/__tests__/tui/console/PreviewPane.test.ts`, `computeLayout.test.ts`: render/state/viewport/memoization/width integration tests. + +## Verification to Date + +- `npm test --workspace ai-devkit -- markdownPreview.test.ts PreviewPane.test.ts computeLayout.test.ts`: 40 tests passed before the final Phase 8 additions. +- `npm run build --workspace ai-devkit`: 196 files compiled; declaration typecheck passed. +- `npm run lint --workspace ai-devkit`: exit 0 with five pre-existing warnings and no errors. +- `npm run test:coverage --workspace ai-devkit`: 82 files / 984 tests passed; global coverage thresholds passed. +- Targeted changed-file coverage: `markdownPreview.ts` 95.9% lines and `PreviewPane.tsx` 98.2% lines. + +## Phase 9 Review + +- **Blocking findings:** none. +- **Important findings:** none. +- Design, requirements, and non-goals align with the final code. +- Export/caller tracing found only internal preview helper contracts; all in-repo callers and tests were updated. +- `marked@15.0.12` is deduplicated with the channel connector and `string-width@8.2.2` is a direct CLI dependency. +- No migration, persisted state, external API, irreversible operation, or rollback hazard exists. +- Entity-encoded controls stay literal, raw HTML stays text, images are never fetched, and links are plain displayed destinations. + +## Conscious Limitations + +- Markdown remains the explicit MVP subset; tables and other unsupported blocks display sanitized source text. +- Extremely narrow widths may place an indivisible wide grapheme beyond the requested single column because terminal graphemes cannot be split safely. +- Component-local layout is rebuilt on message-array or width changes; only offset-only rerenders are guaranteed to do zero parse/layout work. +- Numeric bottom-relative scroll offsets remain; frozen updates and semantic anchors are excluded by design. diff --git a/docs/ai/planning/2026-08-15-feature-agent-console-markdown-preview.md b/docs/ai/planning/2026-08-15-feature-agent-console-markdown-preview.md new file mode 100644 index 00000000..6bae2fa0 --- /dev/null +++ b/docs/ai/planning/2026-08-15-feature-agent-console-markdown-preview.md @@ -0,0 +1,105 @@ +--- +phase: planning +title: Agent Console Markdown Preview Plan +description: Ordered TDD implementation, integration, safety, responsiveness, documentation, and verification tasks. +--- + +# Agent Console Markdown Preview Plan + +## Milestones + +- [x] Milestone 1: Pure safe Markdown-to-terminal-row renderer is complete. +- [x] Milestone 2: Width-aware preview integration preserves scrolling and visible-row-only rendering. +- [x] Milestone 3: Documentation, coverage, verification, and review gates pass. + +## Task Breakdown + +### Phase 1: Markdown row foundation + +- [x] **Task 1.1 — Establish direct dependencies and core inline rendering.** + - Outcome: CLI directly declares the parser/display-width dependencies; headings, paragraphs, bold, emphasis, and inline code produce styled spans. + - Dependencies: approved design; repository Marked precedent. + - TDD: add a focused failing `markdownPreview.test.ts`, run red, install dependencies, implement the minimum parser/token mapping, run green/refactor. + - Validation: targeted unit test, CLI lint/typecheck as useful. + - Testing scenarios: Markdown parsing/layout items 1 and 2 (inline portion). +- [x] **Task 1.2 — Add fenced code, lists, blockquotes, and links.** + - Outcome: remaining MVP constructs render as deterministic prefixed rows and inert label-plus-URL spans. + - Dependencies: Task 1.1 token/span model. + - TDD: one failing behavior at a time for code, lists, quotes, and links, followed by minimal implementation and refactor. + - Validation: targeted renderer tests. + - Testing scenarios: Markdown parsing/layout items 2–4. + +### Phase 2: Safety and physical terminal layout + +- [x] **Task 2.1 — Add safe unsupported/malformed fallbacks and control sanitization.** + - Outcome: HTML/images/tables/malformed source remains readable and inert; ANSI, OSC, and unsafe C0 characters cannot affect the terminal. + - Dependencies: Tasks 1.1–1.2. + - TDD: failing fixtures for each unsafe/fallback class before production changes. + - Validation: targeted safety tests and coverage branches. + - Testing scenarios: Markdown parsing/layout items 5–7 and 9. +- [x] **Task 2.2 — Add display-width wrapping with styled-span preservation.** + - Outcome: content rows reflect physical terminal width, including prefixes, hanging indentation, Unicode, long URLs, and narrow widths. + - Dependencies: stable span/block output from Task 2.1. + - TDD: failing narrow/Unicode/long-token fixtures, then minimum wrapping implementation. + - Validation: targeted layout tests at narrow and normal widths. + - Testing scenarios: Markdown parsing/layout item 8; viewport narrow-width scenario. + +### Phase 3: Preview integration and optimization + +- [x] **Task 3.1 — Integrate styled rows and explicit content width.** + - Outcome: `ConsoleApp` passes `inputInnerWidth - 2`; `PreviewPane` memoizes parse/layout by messages and width, preserves all existing states/headers, and renders styled spans. + - Dependencies: Task 2.2. + - TDD: failing Ink render/width tests before component edits. + - Validation: existing and new `PreviewPane` render tests. + - Testing scenarios: all integration tests plus viewport clamp/indicator behavior. +- [x] **Task 3.2 — Prove viewport-only scroll work.** + - Outcome: viewport slicing occurs before Ink row construction; offset-only rerenders read/lay out source once and reuse stable row objects; append/bottom behavior remains unchanged. + - Dependencies: Task 3.1. + - TDD: failing instrumentation and off-viewport render tests before optimization edits. + - Validation: component rerender, row identity, visible-content, clamp, indicator, and appended-row tests. + - Testing scenarios: all viewport/responsiveness and performance scenarios. + +### Phase 4: Documentation and quality gates + +- [x] **Task 4.1 — Reconcile implementation/testing documents.** + - Outcome: changed files, decisions, deviations, edge cases, test links, checkboxes, and results are current. + - Dependencies: implementation tasks complete. + - Validation: feature lint. +- [x] **Task 4.2 — Run testing, coverage, build, lifecycle lint, and holistic review.** + - Outcome: fresh evidence supports readiness; no blocking design, security, performance, integration, or test finding remains. + - Dependencies: Task 4.1. + - Validation: targeted tests/coverage, CLI lint/build, feature-doc lint, relevant broader tests, git diff review. + +## Dependencies + +- Task order is intentional: tokens/spans → remaining syntax → safety → wrapping → integration → optimization proof → docs/quality gates. +- `marked` and `string-width` must be direct CLI dependencies, not undeclared transitive imports. +- The existing 20-message conversation hook, polling hooks, focus routing, and narrow-mode visibility are not modified. +- Rebase against current `origin/main` before push; if open PR #162 lands, preserve its cached-preview props/metadata during conflict resolution. + +## Timeline & Estimates + +- Foundation and syntax: small-to-medium. +- Safety/wrapping: medium and highest correctness risk. +- Integration/optimization tests: medium. +- Documentation/verification/review: small-to-medium. + +## Risks & Mitigation + +- **Styled wrapping corrupts formatting or widths:** keep pure span/row helpers and test Unicode, prefixes, long tokens, and multiple widths. +- **Malformed tokens crash traversal:** exhaustive safe defaults plus top-level parser fallback. +- **Terminal injection:** sanitize before parsing and again at emitted span boundaries; never emit terminal hyperlinks. +- **Offset rerenders regress CPU:** make memo dependencies explicit and test invocation/object identity. +- **Open console PR conflict:** minimize surface, inspect PR #162, fetch/rebase immediately before submission, and rerun validation. +- **Dependency churn:** align Marked major with existing repository usage and declare only required runtime packages. + +## Resources Needed + +- One isolated feature worktree on `feature-agent-console-markdown-preview`. +- Existing Ink/Vitest test helpers and console responsiveness tests. +- Marked token patterns already used by `packages/channel-connector`. +- AI DevKit task tracing and lifecycle documentation. + +## Progress Summary + +All planned tasks are complete. Forty-two focused tests plus the full 984-test CLI coverage suite cover the renderer, malformed fallback, preview integration, exact terminal width, preserved states, current viewport behavior, offset-only zero parse/layout calls, and visible-row-only Ink output. Phase 9 found no blocking or important issues. No blockers or scope changes are known. diff --git a/docs/ai/requirements/2026-08-15-feature-agent-console-markdown-preview.md b/docs/ai/requirements/2026-08-15-feature-agent-console-markdown-preview.md new file mode 100644 index 00000000..7177af83 --- /dev/null +++ b/docs/ai/requirements/2026-08-15-feature-agent-console-markdown-preview.md @@ -0,0 +1,64 @@ +--- +phase: requirements +title: Agent Console Markdown Preview +description: Render a safe, readable Markdown subset in agent conversation previews without regressing console responsiveness. +--- + +# Agent Console Markdown Preview + +## Problem Statement + +Developers reading agent responses in the Ink agent console currently see Markdown source as unstyled lines. Headings, lists, quotations, code, and links are harder to scan than they are in a rendered conversation. The preview's existing scrolling and memoized row construction are responsiveness-sensitive, so richer rendering must not turn scroll input into Markdown parsing or whole-transcript rendering work. + +## Goals & Objectives + +- Render a deliberately small, readable Markdown subset in conversation message bodies: headings, bold, emphasis, inline code, fenced code blocks, ordered and unordered lists, blockquotes, and links. +- Preserve role headers, timestamps, loading/empty/error states, channel status, the current 20-message tail, focus keys, scroll indicators, bottom-pinned behavior, polling semantics, and narrow-mode behavior. +- Lay out physical terminal rows before viewport slicing so scroll bounds and indicators reflect wrapped rendered content. +- Ensure changing only `scrollOffset` neither reparses Markdown nor rebuilds unchanged laid-out message rows, and create Ink elements only for visible viewport rows. +- Treat conversation content as untrusted display input: remove terminal control sequences and never interpret raw HTML or images. +- Keep rendered state bounded to the active preview through component-local memoization or another demonstrably small bounded cache. + +### Non-goals + +- Syntax highlighting, tables, raw HTML rendering, images, or a rendered/source toggle. +- Loading full conversation history. +- Frozen pending-update UX or semantic scroll anchors. +- A global multi-agent or multi-width render cache. +- Speculative virtualization beyond slicing the already bounded active preview rows. + +## User Stories & Use Cases + +- As a developer, I can scan agent headings, lists, quotes, code, and emphasis without mentally parsing Markdown punctuation. +- As a developer, I can follow links from a terminal-friendly label-and-destination fallback without terminal hyperlinks or unsafe escape sequences. +- As a developer scrolling older output, I retain the current viewport behavior while rapid offset changes avoid parse and layout work. +- As a developer using a narrow or resized terminal, I see correctly wrapped rows and accurate scroll indicators without changing existing narrow-mode navigation. +- As a developer viewing malformed or unsupported Markdown, I see safe readable text instead of a crash, raw control effects, HTML rendering, or image rendering. + +## Success Criteria + +- Focused unit tests cover every supported block and inline construct, malformed input, unsupported HTML/images, and terminal control sanitization. +- Ink render tests preserve the current role/timestamp/state/channel presentation and prove visible Markdown styling/fallback output. +- Viewport tests cover wrapping, visible-row slicing, scroll indicators, offset clamping, bottom-pinned append adjustment, and narrow width. +- An offset-only component rerender proves Markdown source is read once and the same laid-out row objects are reused. +- The CLI targeted tests, lint, typecheck/build, feature-doc lint, and relevant coverage command pass with fresh output. +- Memory remains bounded by the existing active 20-message preview and current width; no global render cache is introduced. + +## Constraints & Assumptions + +- The feature is built on current `origin/main`, which already includes preview-row memoization and console main-thread responsiveness work. +- Open PR #162 also touches `PreviewPane` for cached-state metadata. Its edits are orthogonal; this feature keeps component changes minimal and rebases before submission. +- A direct Markdown parser dependency is acceptable, but rendering is owned by a pure terminal row layer rather than a third-party Ink renderer. +- Terminal layout uses the explicit preview content width. Styled spans are wrapped before viewport slicing. +- Unsupported constructs degrade to sanitized text-oriented fallbacks. Link destinations are shown as plain text; no OSC 8 or other terminal control protocol is emitted. +- Routine styling decisions may use existing design-system colors and Ink text attributes. + +## Alternatives Considered + +1. **Direct parser plus pure terminal row layout (selected):** avoids writing a Markdown grammar while retaining deterministic wrapping, viewport slicing, and testable styles. +2. **Small internal parser:** reduces dependencies but creates avoidable correctness and malformed-input risk for nested inline syntax. +3. **Third-party Ink Markdown renderer:** quick visually, but obscures physical row counts and makes visible-row-only rendering and scroll invariants harder to prove. + +## Questions & Open Items + +No material product questions remain. The explicit MVP scope, safety rules, performance invariant, cache boundary, and non-goals are accepted as authoritative. diff --git a/docs/ai/testing/2026-08-15-feature-agent-console-markdown-preview.md b/docs/ai/testing/2026-08-15-feature-agent-console-markdown-preview.md new file mode 100644 index 00000000..270fe98f --- /dev/null +++ b/docs/ai/testing/2026-08-15-feature-agent-console-markdown-preview.md @@ -0,0 +1,85 @@ +--- +phase: testing +title: Agent Console Markdown Preview Testing Strategy +description: Focused syntax, safety, viewport, responsiveness, integration, and coverage validation. +--- + +# Agent Console Markdown Preview Testing Strategy + +## Test Coverage Goals + +- Target 100% branch/function coverage for the new Markdown/layout module. +- Preserve existing `PreviewPane` render and viewport regression coverage. +- Test user-observable output and style-bearing row data, with mocks only for invocation counting where necessary. + +## Unit Tests + +### Markdown parsing and layout + +- [x] Render headings, bold, emphasis, and combined inline spans. +- [x] Render inline code and fenced code blocks, including optional language labels without highlighting. +- [x] Render ordered/unordered lists with hanging indentation and blockquotes with terminal prefixes. +- [x] Render links as safe label-plus-destination text. +- [x] Fall back safely for malformed Markdown and unsupported tables. +- [x] Preserve raw HTML and images only as sanitized inert text fallbacks. +- [x] Strip ANSI, OSC, and unsafe C0 terminal control sequences. +- [x] Wrap styled spans by terminal display width at narrow and normal widths. +- [x] Preserve blank lines and empty message bodies without crashes. + +### Viewport and responsiveness + +- [x] Clamp offsets and show above/below/continuation indicators using rendered physical rows. +- [x] Keep the bottom pinned at offset zero and adjust positive offsets when rendered rows append. +- [x] Slice rows before Ink element creation and render no off-viewport content. +- [x] Prove an offset-only rerender does not re-read Markdown source or rebuild stable laid-out row objects. +- [x] Prove width changes rebuild layout and messages changes rebuild only the active bounded preview result. + +## Integration Tests + +- [x] Render a mixed user/assistant conversation with role headers, timestamps, Markdown styles, and separators. +- [x] Preserve loading, empty, error, selected-agent, and channel-connected states. +- [x] Preserve `PreviewSection` width plumbing and current focus/scroll callback behavior. +- [x] Preserve the existing 20-message tail and polling behavior by leaving the conversation hook contract unchanged. + +## End-to-End Tests + +- [x] Run the CLI console render suite at normal terminal width. +- [x] Run narrow-width fixtures to verify wrapping and viewport indicators without changing narrow-mode behavior. +- [x] Regression-check open console responsiveness integration points through current targeted suites. + +## Test Data + +- Mixed Markdown fixture containing all supported constructs. +- Malformed fence/emphasis/link fixtures. +- Raw HTML, image, ANSI/OSC, C0 control, Unicode, long-word, and long-URL fixtures. +- Existing `AgentInfo`, `ConversationMessage`, channel status, and fetch-error fixtures. + +## Test Reporting & Coverage + +- `npm test --workspace ai-devkit -- PreviewPane.test.ts markdownPreview.test.ts` +- `npm run test:coverage --workspace ai-devkit -- PreviewPane.test.ts markdownPreview.test.ts` +- `npm run lint --workspace ai-devkit` +- `npm run build --workspace ai-devkit` +- `npx ai-devkit@latest lint --feature agent-console-markdown-preview` +- `npm run test:coverage --workspace ai-devkit` — exit 0; 82 files and 984 tests passed. Global coverage: 74.72% statements, 65.12% branches, 74.46% functions, 75.82% lines. +- Targeted three-file coverage ran 42 tests and reported `markdownPreview.ts` at 94.28% statements / 82.14% branches / 97.22% functions / 95.9% lines and `PreviewPane.tsx` at 96.55% statements / 84.41% branches / 94.44% functions / 98.18% lines. That targeted-only command exits 1 because unrelated unexecuted CLI modules reduce package-global coverage below 60%; the full suite above is the valid threshold result. +- `npm run lint --workspace ai-devkit` — exit 0; no errors, five pre-existing unused-catch warnings. +- `npm run build --workspace ai-devkit` — exit 0; 196 files compiled and declaration typecheck passed. +- `npx ai-devkit@latest lint --feature agent-console-markdown-preview` — exit 0. +- Remaining changed-file coverage gaps are the unavoidable single-grapheme-wider-than-viewport branch and defensive lexer-throw fallback; both degrade to visible literal output and are conscious non-blocking limitations. + +## Manual Testing + +- Inspect stripped Ink output for readable hierarchy, list/quote/code prefixes, safe link fallback, and correct scroll indicators. +- Confirm unsupported content remains inert and readable. +- Completed through deterministic Ink `renderToString` fixtures at normal/narrow widths: Markdown punctuation is removed, role/timestamp/channel chrome remains, and off-viewport sentinel content is absent. + +## Performance Testing + +- Use invocation/object-identity assertions during component rerender: changing only `scrollOffset` must produce zero additional source reads and reuse the prior laid-out rows. +- Assert only viewport rows appear in rendered output; the active source remains capped by the existing hook at twenty messages. + +## Bug Tracking + +- Any safety, offset-only recomputation, viewport overflow, or state regression is blocking. +- Styling preferences that do not affect readability or contracts are non-blocking follow-ups. diff --git a/package-lock.json b/package-lock.json index a8aae872..22f44977 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10532,6 +10532,18 @@ "dev": true, "license": "ISC" }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "license": "MIT", @@ -13608,16 +13620,6 @@ "node": ">=20.20.0" } }, - "packages/channel-connector/node_modules/marked": { - "version": "15.0.12", - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 18" - } - }, "packages/cli": { "name": "ai-devkit", "version": "0.50.0", @@ -13634,9 +13636,11 @@ "gray-matter": "^4.0.3", "ink": "^7.0.4", "ink-text-input": "^6.0.0", + "marked": "^15.0.12", "ora": "^9.0.0", "react": "^19.2.0", "smol-toml": "^1.6.1", + "string-width": "^8.2.2", "uuid": "14.0.0", "yaml": "^2.3.4", "zod": "^3.25.76" @@ -13820,9 +13824,9 @@ } }, "packages/cli/node_modules/string-width": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", - "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", "license": "MIT", "dependencies": { "get-east-asian-width": "^1.5.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index f4c390ee..d101cb12 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -45,9 +45,11 @@ "gray-matter": "^4.0.3", "ink": "^7.0.4", "ink-text-input": "^6.0.0", + "marked": "^15.0.12", "ora": "^9.0.0", "react": "^19.2.0", "smol-toml": "^1.6.1", + "string-width": "^8.2.2", "uuid": "14.0.0", "yaml": "^2.3.4", "zod": "^3.25.76" diff --git a/packages/cli/src/__tests__/tui/console/PreviewPane.test.ts b/packages/cli/src/__tests__/tui/console/PreviewPane.test.ts index a9b9dc70..54ca4d23 100644 --- a/packages/cli/src/__tests__/tui/console/PreviewPane.test.ts +++ b/packages/cli/src/__tests__/tui/console/PreviewPane.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import React from 'react'; import { render, renderToString } from 'ink'; import { PassThrough } from 'node:stream'; @@ -11,6 +11,7 @@ import { PreviewPane, } from '../../../tui/console/PreviewPane.js'; import { AgentStatus, type AgentInfo, type ConversationMessage } from '@ai-devkit/agent-manager'; +import * as markdownPreview from '../../../tui/console/render/markdownPreview.js'; const messages: ConversationMessage[] = [ { role: 'user', content: 'first question', timestamp: '2026-07-02T10:00:00Z' }, @@ -19,6 +20,10 @@ const messages: ConversationMessage[] = [ { role: 'assistant', content: 'second answer', timestamp: '2026-07-02T10:00:03Z' }, ]; +afterEach(() => { + vi.restoreAllMocks(); +}); + describe('PreviewPane helpers', () => { it('uses success tone when the selected agent has channel status', () => { expect(getPreviewPanelTone({ channelName: 'telegram', channelType: 'telegram', bridgePid: 42 })).toBe('success'); @@ -42,7 +47,7 @@ describe('PreviewPane helpers', () => { expect(viewport.rows).toEqual([ { kind: 'indicator', text: '↑ older', role: null }, { kind: 'header', text: '', role: 'assistant', timestamp: '2026-07-02T10:00:03Z' }, - { kind: 'content', text: 'second answer', role: 'assistant' }, + { kind: 'content', text: 'second answer', role: 'assistant', spans: [{ text: 'second answer' }] }, ]); }); @@ -56,7 +61,7 @@ describe('PreviewPane helpers', () => { expect(viewport.rows).toEqual([ { kind: 'indicator', text: ' ↓ newer', role: null }, { kind: 'header', text: '', role: 'user', timestamp: '2026-07-02T10:00:00Z' }, - { kind: 'content', text: 'first question', role: 'user' }, + { kind: 'content', text: 'first question', role: 'user', spans: [{ text: 'first question' }] }, ]); }); @@ -85,9 +90,14 @@ describe('PreviewPane helpers', () => { expect(viewport.rows).toEqual([ { kind: 'header', text: '', role: 'assistant', timestamp: '2026-07-02T10:00:00Z' }, - { kind: 'content', text: 'Summary', role: 'assistant' }, - { kind: 'content', text: '', role: 'assistant' }, - { kind: 'content', text: '- first item', role: 'assistant' }, + { kind: 'content', text: 'Summary', role: 'assistant', spans: [{ text: 'Summary' }] }, + { kind: 'content', text: '', role: 'assistant', spans: [{ text: '' }] }, + { + kind: 'content', + text: '• first item', + role: 'assistant', + spans: [{ text: '• ' }, { text: 'first item' }], + }, ]); }); @@ -115,13 +125,68 @@ describe('PreviewPane helpers', () => { expect(output).not.toContain('assistant │ first answer'); }); + it('renders Markdown message bodies without source punctuation', () => { + const agent = { + name: 'preview-test', + type: 'codex', + status: AgentStatus.RUNNING, + projectPath: '/tmp/project', + lastActive: new Date(), + } as AgentInfo; + const output = stripVTControlCharacters(renderToString(React.createElement(PreviewPane, { + agent, + messages: [{ + role: 'assistant', + content: '# Heading\n\nUse **bold** and [docs](https://example.com).', + timestamp: '2026-07-02T10:00:00Z', + }], + error: null, + isLoading: false, + maxLines: 8, + }), { columns: 80 })); + + expect(output).toContain('assistant:\n Heading\n Use bold and docs (https://example.com).'); + expect(output).not.toContain('# Heading'); + expect(output).not.toContain('**bold**'); + expect(output).not.toContain('[docs]'); + expect(output).toMatch(/\[[^\]]+\] assistant:/u); + }); + + it('preserves selected, loading, empty, error, and channel status states', () => { + const agent = { + name: 'preview-test', + type: 'codex', + status: AgentStatus.RUNNING, + projectPath: '/tmp/project', + lastActive: new Date(), + } as AgentInfo; + const renderPane = (props: Partial>) => + stripVTControlCharacters(renderToString(React.createElement(PreviewPane, { + agent, + messages: [], + error: null, + isLoading: false, + ...props, + }), { columns: 80 })); + + expect(renderPane({ agent: null })).toContain('No agent selected.'); + expect(renderPane({ isLoading: true })).toContain('loading…'); + expect(renderPane({})).toContain('No messages yet.'); + expect(renderPane({ + error: { kind: 'no-session-file', message: 'missing' }, + })).toContain('No session file available for this agent yet.'); + expect(renderPane({ + channelStatus: { channelName: 'telegram', channelType: 'telegram', bridgePid: 42 }, + })).toContain('Connected: telegram'); + }); + it('adjusts positive scroll offsets by newly appended rendered rows', () => { expect(adjustPreviewScrollOffsetForAppendedRows(5, 7, 2)).toBe(4); expect(adjustPreviewScrollOffsetForAppendedRows(5, 7, 0)).toBe(0); expect(adjustPreviewScrollOffsetForAppendedRows(7, 5, 2)).toBe(2); }); - it('reuses flattened rows when only the scroll offset changes', async () => { + it('does not reparse Markdown or rebuild laid-out rows when only scroll offset changes', async () => { const agent = { name: 'preview-test', type: 'codex', @@ -138,6 +203,7 @@ describe('PreviewPane helpers', () => { }, }); const stableMessages = [message]; + const renderRowsSpy = vi.spyOn(markdownPreview, 'renderMarkdownRows'); const stdout = new PassThrough() as unknown as NodeJS.WriteStream; const preview = (scrollOffset: number) => React.createElement(PreviewPane, { agent, @@ -150,13 +216,75 @@ describe('PreviewPane helpers', () => { const instance = render(preview(0), { stdout, interactive: false, patchConsole: false }); await instance.waitUntilRenderFlush(); const readsAfterInitialRender = contentReads; + const layoutCallsAfterInitialRender = renderRowsSpy.mock.calls.length; expect(readsAfterInitialRender).toBe(1); + expect(layoutCallsAfterInitialRender).toBe(1); instance.rerender(preview(1)); await instance.waitUntilRenderFlush(); expect(contentReads).toBe(readsAfterInitialRender); + expect(renderRowsSpy).toHaveBeenCalledTimes(layoutCallsAfterInitialRender); + instance.unmount(); + await instance.waitUntilExit(); + renderRowsSpy.mockRestore(); + }); + + it('rebuilds layout when content width changes while messages stay stable', async () => { + const agent = { + name: 'preview-test', + type: 'codex', + status: AgentStatus.RUNNING, + projectPath: '/tmp/project', + lastActive: new Date(), + } as AgentInfo; + const stableMessages: ConversationMessage[] = [{ + role: 'assistant', + content: 'alpha beta gamma delta', + }]; + const renderRowsSpy = vi.spyOn(markdownPreview, 'renderMarkdownRows'); + const stdout = new PassThrough() as unknown as NodeJS.WriteStream; + const preview = (contentWidth: number) => React.createElement(PreviewPane, { + agent, + messages: stableMessages, + error: null, + isLoading: false, + maxLines: 8, + contentWidth, + }); + const instance = render(preview(40), { stdout, interactive: false, patchConsole: false }); + await instance.waitUntilRenderFlush(); + expect(renderRowsSpy).toHaveBeenCalledTimes(1); + + instance.rerender(preview(10)); + await instance.waitUntilRenderFlush(); + + expect(renderRowsSpy).toHaveBeenCalledTimes(2); instance.unmount(); await instance.waitUntilExit(); }); + + it('renders only rows selected by the visible viewport slice', () => { + const agent = { + name: 'preview-test', + type: 'codex', + status: AgentStatus.RUNNING, + projectPath: '/tmp/project', + lastActive: new Date(), + } as AgentInfo; + const output = stripVTControlCharacters(renderToString(React.createElement(PreviewPane, { + agent, + messages: [ + { role: 'user', content: 'OFF_VIEWPORT_SENTINEL' }, + { role: 'assistant', content: 'newest answer' }, + ], + error: null, + isLoading: false, + maxLines: 3, + scrollOffset: 0, + }), { columns: 80 })); + + expect(output).toContain('newest answer'); + expect(output).not.toContain('OFF_VIEWPORT_SENTINEL'); + }); }); diff --git a/packages/cli/src/__tests__/tui/console/computeLayout.test.ts b/packages/cli/src/__tests__/tui/console/computeLayout.test.ts index 8ed478d4..927dbc13 100644 --- a/packages/cli/src/__tests__/tui/console/computeLayout.test.ts +++ b/packages/cli/src/__tests__/tui/console/computeLayout.test.ts @@ -39,6 +39,11 @@ describe('computeLayout', () => { expect(layout.inputInnerWidth).toBe(layout.rightColWidth - 4); }); + it('reserves the existing two-column message indent from preview Markdown width', () => { + const layout = computeLayout(160, 40, 1, false); + expect(layout.previewContentWidth).toBe(layout.inputInnerWidth - 2); + }); + it('previewHeight is contentHeight minus inputBoxHeight', () => { const layout = computeLayout(160, 40, 1, false); expect(layout.previewHeight).toBe(layout.contentHeight - layout.inputBoxHeight); diff --git a/packages/cli/src/__tests__/tui/console/render/markdownPreview.test.ts b/packages/cli/src/__tests__/tui/console/render/markdownPreview.test.ts new file mode 100644 index 00000000..a8f82604 --- /dev/null +++ b/packages/cli/src/__tests__/tui/console/render/markdownPreview.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; +import { renderMarkdownRows } from '../../../../tui/console/render/markdownPreview.js'; + +describe('renderMarkdownRows', () => { + it('renders headings and inline emphasis as styled terminal spans', () => { + expect(renderMarkdownRows('# Heading\nPlain **bold** and *soft* with `code`', 80)).toEqual([ + [{ text: 'Heading', bold: true, color: 'cyan' }], + [ + { text: 'Plain ' }, + { text: 'bold', bold: true }, + { text: ' and ' }, + { text: 'soft', italic: true }, + { text: ' with ' }, + { text: 'code', color: 'yellow' }, + ], + ]); + }); + + it('renders fenced code as literal styled rows with a language label', () => { + expect(renderMarkdownRows('```ts\nconst answer = 42;\nreturn answer;\n```', 80)).toEqual([ + [{ text: '```ts', dimColor: true }], + [{ text: ' ' }, { text: 'const answer = 42;', color: 'yellow' }], + [{ text: ' ' }, { text: 'return answer;', color: 'yellow' }], + [{ text: '```', dimColor: true }], + ]); + }); + + it('renders unordered and ordered list markers with inline styles', () => { + expect(renderMarkdownRows('- first **item**\n- second\n\n3. third\n4. fourth', 80)).toEqual([ + [{ text: '• ' }, { text: 'first ' }, { text: 'item', bold: true }], + [{ text: '• ' }, { text: 'second' }], + [{ text: '' }], + [{ text: '3. ' }, { text: 'third' }], + [{ text: '4. ' }, { text: 'fourth' }], + ]); + }); + + it('renders blockquotes with a dim terminal prefix', () => { + expect(renderMarkdownRows('> quoted **boldly**', 80)).toEqual([ + [{ text: '│ ', dimColor: true }, { text: 'quoted ' }, { text: 'boldly', bold: true }], + ]); + }); + + it('renders links as a terminal-friendly label and inert destination', () => { + expect(renderMarkdownRows('See [the docs](https://example.com/guide).', 80)).toEqual([ + [ + { text: 'See ' }, + { text: 'the docs', color: 'cyan' }, + { text: ' (https://example.com/guide)', dimColor: true }, + { text: '.' }, + ], + ]); + }); + + it('keeps raw HTML, images, and unsupported tables inert and readable', () => { + const rows = renderMarkdownRows( + 'not rendered\n\n![diagram](https://example.com/image.png)\n\n| A | B |\n| - | - |\n| 1 | 2 |', + 80, + ); + + expect(rows).toEqual([ + [{ text: '' }, { text: 'not rendered' }, { text: '' }], + [{ text: '' }], + [ + { text: '[image: diagram]', dimColor: true }, + { text: ' (https://example.com/image.png)', dimColor: true }, + ], + [{ text: '' }], + [{ text: '| A | B |' }], + [{ text: '| - | - |' }], + [{ text: '| 1 | 2 |' }], + ]); + }); + + it('removes ANSI, OSC, and unsafe control characters before parsing', () => { + const source = '\u001B[31m# Red\u001B[0m\nText\u0000 safe \u001B]8;;https://evil.test\u0007click\u001B]8;;\u0007'; + const rows = renderMarkdownRows(source, 80); + const plainText = rows.map(row => row.map(span => span.text).join('')).join('\n'); + + expect(plainText).toBe('Red\nText safe click'); + expect(Array.from(plainText).some((character) => { + const code = character.codePointAt(0) ?? 0; + return code <= 8 || code === 11 || code === 12 + || (code >= 14 && code <= 31) || (code >= 127 && code <= 159); + })).toBe(false); + }); + + it('keeps an empty message as one safe blank terminal row', () => { + expect(renderMarkdownRows('', 80)).toEqual([[{ text: '' }]]); + }); + + it('wraps list content to physical rows with hanging indentation', () => { + expect(renderMarkdownRows('- alpha beta gamma', 10)).toEqual([ + [{ text: '• ' }, { text: 'alpha' }], + [{ text: ' ' }, { text: 'beta' }], + [{ text: ' ' }, { text: 'gamma' }], + ]); + }); + + it('does not let a wide grapheme overflow a narrow continuation prefix', () => { + expect(renderMarkdownRows('- 界x', 3)).toEqual([ + [{ text: '• ' }], + [{ text: '界x' }], + ]); + }); + + it('turns Markdown source line breaks into separately counted terminal rows', () => { + expect(renderMarkdownRows('first line\nsecond **line**', 80)).toEqual([ + [{ text: 'first line' }], + [{ text: 'second ' }, { text: 'line', bold: true }], + ]); + }); + + it('marks an unterminated fenced block as malformed but keeps its content readable', () => { + expect(renderMarkdownRows('```ts\nconst answer = 42;', 80)).toEqual([ + [{ text: '```ts', dimColor: true }], + [{ text: ' ' }, { text: 'const answer = 42;', color: 'yellow' }], + [{ text: '[unterminated code fence]', dimColor: true }], + ]); + }); +}); diff --git a/packages/cli/src/tui/console/ConsoleApp.tsx b/packages/cli/src/tui/console/ConsoleApp.tsx index 06a41a90..24eaad6e 100644 --- a/packages/cli/src/tui/console/ConsoleApp.tsx +++ b/packages/cli/src/tui/console/ConsoleApp.tsx @@ -65,13 +65,15 @@ export function computeLayout(cols: number, rows: number, inputLines: number, na const contentHeight = Math.max(MIN_CONTENT_HEIGHT, totalHeight - FOOTER_HEIGHT - HEADER_HEIGHT); const listPaneWidth = narrow ? cols - 2 : LIST_PANE_WIDTH; const rightColWidth = Math.max(20, cols - listPaneWidth - 1); + const inputInnerWidth = Math.max(4, rightColWidth - 4); return { inputBoxHeight, contentHeight, previewHeight: contentHeight - inputBoxHeight, listPaneWidth, rightColWidth, - inputInnerWidth: Math.max(4, rightColWidth - 4), + inputInnerWidth, + previewContentWidth: Math.max(1, inputInnerWidth - 2), }; } @@ -301,7 +303,15 @@ const ConsoleAppShell: React.FC<{ const { cols, rows } = useTerminalSize(); const narrow = cols < NARROW_THRESHOLD_COLS; const layout = computeLayout(cols, rows, inputLines, narrow); - const { inputBoxHeight, contentHeight, previewHeight, listPaneWidth, rightColWidth, inputInnerWidth } = layout; + const { + inputBoxHeight, + contentHeight, + previewHeight, + listPaneWidth, + rightColWidth, + inputInnerWidth, + previewContentWidth, + } = layout; const dialog = computeCenteredDialog(cols, rows); const startPane = ( void; } @@ -36,6 +41,7 @@ export interface PreviewViewportRow { text: string; role: ConversationMessage['role'] | null; timestamp?: string; + spans?: MarkdownPreviewSpan[]; } export interface PreviewViewport { @@ -46,22 +52,24 @@ export interface PreviewViewport { hasBelow: boolean; } -export function buildPreviewRows(messages: ConversationMessage[]): PreviewViewportRow[] { +export function buildPreviewRows(messages: ConversationMessage[], contentWidth = 80): PreviewViewportRow[] { return messages.flatMap((msg, index) => { - const contentLines = msg.content.split('\n'); + const contentRows = renderMarkdownRows(msg.content, contentWidth); return [ ...(index > 0 ? [{ kind: 'separator' as const, text: '', role: null }] : []), { kind: 'header', text: '', role: msg.role, timestamp: msg.timestamp }, - ...contentLines.map(line => ({ kind: 'content', text: line, role: msg.role })), + ...contentRows.map(spans => ({ + kind: 'content', + text: spans.map(span => span.text).join(''), + role: msg.role, + spans, + })), ]; }); } -export function countPreviewRows(messages: ConversationMessage[]): number { - return messages.reduce( - (total, msg, index) => total + Math.max(1, msg.content.split('\n').length) + 1 + (index > 0 ? 1 : 0), - 0, - ); +export function countPreviewRows(messages: ConversationMessage[], contentWidth = 80): number { + return buildPreviewRows(messages, contentWidth).length; } export function adjustPreviewScrollOffsetForAppendedRows( @@ -110,8 +118,9 @@ export function buildPreviewViewport( messages: ConversationMessage[], maxLines: number, requestedOffset: number, + contentWidth = 80, ): PreviewViewport { - return buildPreviewViewportFromRows(buildPreviewRows(messages), maxLines, requestedOffset); + return buildPreviewViewportFromRows(buildPreviewRows(messages, contentWidth), maxLines, requestedOffset); } export function getPreviewPanelTone(channelStatus: AgentChannelStatus | undefined): PanelTone { @@ -150,9 +159,10 @@ const PreviewPaneInner: React.FC = ({ maxLines = 22, channelStatus, scrollOffset = 0, + contentWidth = 80, onScrollOffsetClamp, }) => { - const rows = useMemo(() => buildPreviewRows(messages), [messages]); + const rows = useMemo(() => buildPreviewRows(messages, contentWidth), [messages, contentWidth]); const rowCount = rows.length; const previousRowCountRef = useRef(rowCount); const adjustedScrollOffset = adjustPreviewScrollOffsetForAppendedRows( @@ -217,7 +227,23 @@ const PreviewPaneInner: React.FC = ({ - {row.text || ' '} + + {row.spans + ? row.text + ? row.spans.map((span, spanIndex) => ( + + {span.text} + + )) + : ' ' + : row.text || ' '} + ) diff --git a/packages/cli/src/tui/console/PreviewSection.tsx b/packages/cli/src/tui/console/PreviewSection.tsx index 425a66db..5f471bb7 100644 --- a/packages/cli/src/tui/console/PreviewSection.tsx +++ b/packages/cli/src/tui/console/PreviewSection.tsx @@ -11,6 +11,7 @@ import { getPreviewPanelTone } from './PreviewPane.js'; interface PreviewSectionProps { selectedName: string | null; height: number; + contentWidth?: number; focused?: boolean; scrollOffset?: number; onScrollOffsetClamp?: (offset: number) => void; @@ -19,6 +20,7 @@ interface PreviewSectionProps { const PreviewSectionInner: React.FC = ({ selectedName, height, + contentWidth = 80, focused = false, scrollOffset = 0, onScrollOffsetClamp, @@ -52,6 +54,7 @@ const PreviewSectionInner: React.FC = ({ error={error} isLoading={isLoading} maxLines={Math.max(4, height - 2)} + contentWidth={contentWidth} channelStatus={channelStatus} scrollOffset={scrollOffset} onScrollOffsetClamp={onScrollOffsetClamp} diff --git a/packages/cli/src/tui/console/render/markdownPreview.ts b/packages/cli/src/tui/console/render/markdownPreview.ts new file mode 100644 index 00000000..c2aeb5f6 --- /dev/null +++ b/packages/cli/src/tui/console/render/markdownPreview.ts @@ -0,0 +1,272 @@ +import { Marked, type Token, type Tokens } from 'marked'; +import { stripVTControlCharacters } from 'node:util'; +import stringWidth from 'string-width'; +import { TUI_COLORS } from '../../design-system/index.js'; + +export interface MarkdownPreviewSpan { + text: string; + bold?: boolean; + italic?: boolean; + dimColor?: boolean; + color?: string; +} + +export type MarkdownPreviewRow = MarkdownPreviewSpan[]; + +const markdown = new Marked(); +const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); + +function isUnsafeControl(character: string): boolean { + const code = character.codePointAt(0) ?? 0; + return code <= 8 + || code === 11 + || code === 12 + || (code >= 14 && code <= 31) + || (code >= 127 && code <= 159); +} + +export function sanitizeMarkdownSource(source: string): string { + return stripVTControlCharacters(source) + .replace(/\r\n?/g, '\n') + .replace(/\t/g, ' ') + .split('') + .filter(character => !isUnsafeControl(character)) + .join(''); +} + +function applyStyle( + spans: MarkdownPreviewSpan[], + style: Omit, +): MarkdownPreviewSpan[] { + return spans.map(span => ({ ...span, ...style })); +} + +function literalRows(raw: string): MarkdownPreviewRow[] { + return raw.replace(/\n$/, '').split('\n').map(text => [{ text }]); +} + +function hasClosingCodeFence(raw: string): boolean { + const lastLine = raw.trimEnd().split('\n').at(-1) ?? ''; + return /^ {0,3}(?:`{3,}|~{3,})\s*$/u.test(lastLine); +} + +function splitMarkdownRowAtNewlines(row: MarkdownPreviewRow): MarkdownPreviewRow[] { + const lines: MarkdownPreviewRow[] = [[]]; + for (const span of row) { + const parts = span.text.split('\n'); + parts.forEach((part, index) => { + if (part) lines.at(-1)?.push({ ...span, text: part }); + if (index < parts.length - 1) lines.push([]); + }); + } + return lines.map(line => line.length > 0 ? line : [{ text: '' }]); +} + +function isRowPrefix(span: MarkdownPreviewSpan | undefined): boolean { + return Boolean(span && ( + span.text === ' ' + || span.text === '│ ' + || span.text === '• ' + || /^\d+\. $/u.test(span.text) + )); +} + +function continuationPrefix(span: MarkdownPreviewSpan, width: number): MarkdownPreviewSpan[] { + const prefix = span.text === '│ ' || span.text === ' ' + ? { ...span } + : { text: ' '.repeat(stringWidth(span.text)) }; + return stringWidth(prefix.text) < width ? [prefix] : []; +} + +function splitByDisplayWidth(text: string, available: number): [string, string] { + let head = ''; + let used = 0; + const segments = Array.from(graphemeSegmenter.segment(text), item => item.segment); + let index = 0; + for (; index < segments.length; index += 1) { + const segmentWidth = stringWidth(segments[index]); + if (head && used + segmentWidth > available) break; + if (!head && segmentWidth > available) { + head = segments[index]; + index += 1; + break; + } + head += segments[index]; + used += segmentWidth; + } + return [head, segments.slice(index).join('')]; +} + +export function wrapMarkdownRow(row: MarkdownPreviewRow, requestedWidth: number): MarkdownPreviewRow[] { + const width = Math.max(1, Math.floor(requestedWidth)); + if (row.reduce((total, span) => total + stringWidth(span.text), 0) <= width) return [row]; + + const prefixSpan = isRowPrefix(row[0]) ? row[0] : undefined; + const continuation = prefixSpan ? continuationPrefix(prefixSpan, width) : []; + const content = prefixSpan ? row.slice(1) : row; + const output: MarkdownPreviewRow[] = []; + let current = prefixSpan ? [{ ...prefixSpan }] : []; + let currentWidth = current.reduce((total, span) => total + stringWidth(span.text), 0); + let pendingWhitespace: MarkdownPreviewSpan[] = []; + + const resetLine = (): void => { + current = continuation.map(span => ({ ...span })); + currentWidth = current.reduce((total, span) => total + stringWidth(span.text), 0); + }; + const flush = (): void => { + output.push(current.length > 0 ? current : [{ text: '' }]); + resetLine(); + }; + const append = (span: MarkdownPreviewSpan): void => { + current.push(span); + currentWidth += stringWidth(span.text); + }; + + for (const span of content) { + for (const piece of span.text.match(/\s+|\S+/gu) ?? ['']) { + if (/^\s+$/u.test(piece)) { + pendingWhitespace.push({ ...span, text: piece }); + continue; + } + + const whitespaceWidth = pendingWhitespace.reduce( + (total, whitespace) => total + stringWidth(whitespace.text), + 0, + ); + let remaining = piece; + const pieceWidth = stringWidth(piece); + if (currentWidth + whitespaceWidth + pieceWidth > width && currentWidth > 0) { + flush(); + pendingWhitespace = []; + } else { + pendingWhitespace.forEach(append); + pendingWhitespace = []; + } + + while (remaining) { + const firstGrapheme = graphemeSegmenter.segment(remaining)[Symbol.iterator]().next().value?.segment ?? ''; + if ( + current.length === continuation.length + && currentWidth > 0 + && stringWidth(firstGrapheme) > width - currentWidth + ) { + current = []; + currentWidth = 0; + } + const available = Math.max(1, width - currentWidth); + const [head, tail] = splitByDisplayWidth(remaining, available); + append({ ...span, text: head }); + remaining = tail; + if (remaining) flush(); + } + } + } + + const isBareContinuation = current.length === continuation.length + && current.every((span, index) => span.text === continuation[index]?.text); + if (!isBareContinuation || output.length === 0) output.push(current); + return output; +} + +function renderInline(tokens: Token[]): MarkdownPreviewSpan[] { + return tokens.flatMap((token) => { + switch (token.type) { + case 'text': { + const text = token as Tokens.Text; + return text.tokens ? renderInline(text.tokens) : [{ text: text.text }]; + } + case 'strong': + return applyStyle(renderInline((token as Tokens.Strong).tokens), { bold: true }); + case 'em': + return applyStyle(renderInline((token as Tokens.Em).tokens), { italic: true }); + case 'codespan': + return [{ text: (token as Tokens.Codespan).text, color: TUI_COLORS.warning }]; + case 'link': { + const link = token as Tokens.Link; + const label = applyStyle(renderInline(link.tokens), { color: TUI_COLORS.accent }); + const labelText = label.map(span => span.text).join(''); + return labelText === link.href + ? label + : [...label, { text: ` (${link.href})`, dimColor: true }]; + } + case 'image': { + const image = token as Tokens.Image; + const label = image.text || 'image'; + return [ + { text: `[image: ${label}]`, dimColor: true }, + { text: ` (${image.href})`, dimColor: true }, + ]; + } + default: + return [{ text: token.raw ?? '' }]; + } + }); +} + +function renderBlock(token: Token): MarkdownPreviewRow[] { + switch (token.type) { + case 'heading': + return [applyStyle(renderInline((token as Tokens.Heading).tokens), { + bold: true, + color: TUI_COLORS.accent, + })]; + case 'paragraph': + return [renderInline((token as Tokens.Paragraph).tokens)]; + case 'code': { + const code = token as Tokens.Code; + const language = code.lang?.trim().split(/\s+/)[0] ?? ''; + return [ + [{ text: `\`\`\`${language}`, dimColor: true }], + ...code.text.split('\n').map(line => [ + { text: ' ' }, + { text: line, color: TUI_COLORS.warning }, + ]), + [{ + text: hasClosingCodeFence(code.raw) ? '```' : '[unterminated code fence]', + dimColor: true, + }], + ]; + } + case 'list': { + const list = token as Tokens.List; + const start = typeof list.start === 'number' ? list.start : 1; + return list.items.flatMap((item, index) => { + const marker = list.ordered ? `${start + index}. ` : '• '; + const itemRows = item.tokens.flatMap(renderBlock); + if (itemRows.length === 0) return [[{ text: marker.trimEnd() }]]; + return itemRows.map((row, rowIndex) => [ + { text: rowIndex === 0 ? marker : ' '.repeat(marker.length) }, + ...row, + ]); + }); + } + case 'blockquote': + return (token as Tokens.Blockquote).tokens.flatMap(renderBlock).map(row => [ + { text: '│ ', dimColor: true }, + ...row, + ]); + case 'text': { + const text = token as Tokens.Text; + return [text.tokens ? renderInline(text.tokens) : [{ text: text.text }]]; + } + case 'space': + return [[{ text: '' }]]; + default: + return literalRows(token.raw ?? ''); + } +} + +export function renderMarkdownRows(source: string, _width: number): MarkdownPreviewRow[] { + const sanitized = sanitizeMarkdownSource(source); + try { + const rows = markdown.lexer(sanitized).flatMap(renderBlock); + const safeRows = rows.length > 0 ? rows : [[{ text: '' }]]; + return safeRows + .flatMap(splitMarkdownRowAtNewlines) + .flatMap(row => wrapMarkdownRow(row, _width)); + } catch { + return literalRows(sanitized) + .flatMap(splitMarkdownRowAtNewlines) + .flatMap(row => wrapMarkdownRow(row, _width)); + } +}