You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/10/22 22:06:44 UTC
(maka) 16/29: fix(terminal-shell): address rendered history by original source positions
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch rust-preview
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 614d523831d3a721c6d78e692e642de878528bb7
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Fri Oct 23 04:38:14 2026 +0800
fix(terminal-shell): address rendered history by original source positions
---
products/maka/terminal-shell/app/MarkdownBlock.tsx | 42 ++-
products/maka/terminal-shell/app/MarkdownBody.tsx | 5 +-
products/maka/terminal-shell/app/MessageRow.tsx | 18 +-
products/maka/terminal-shell/app/SourceText.tsx | 37 ++
products/maka/terminal-shell/src/markdown.rs | 151 +++++++-
.../maka/terminal-shell/src/markdown/source.rs | 418 +++++++++++++++++++++
products/maka/terminal-shell/tests/real_host.rs | 5 +
7 files changed, 648 insertions(+), 28 deletions(-)
diff --git a/products/maka/terminal-shell/app/MarkdownBlock.tsx b/products/maka/terminal-shell/app/MarkdownBlock.tsx
index 17e2f3c50e..0d56f76c6b 100644
--- a/products/maka/terminal-shell/app/MarkdownBlock.tsx
+++ b/products/maka/terminal-shell/app/MarkdownBlock.tsx
@@ -36,6 +36,7 @@ import { Icon } from './Icon';
export function MarkdownBlockView(props: {
block: () => MarkdownBlock;
+ sourceId: string;
compact?: boolean;
openLink(target: string, restoreFocus: () => boolean): Promise<void>;
userScroll(): void;
@@ -51,6 +52,7 @@ export function MarkdownBlockView(props: {
else={
<MarkdownBlockContent
block={block}
+ sourceId={props.sourceId}
compact={props.compact}
openLink={props.openLink}
userScroll={props.userScroll}
@@ -76,6 +78,7 @@ export function MarkdownBlockView(props: {
<View style={{ flex: 1, minWidth: 0 }}>
<MarkdownBlockContent
block={block}
+ sourceId={props.sourceId}
compact={props.compact}
openLink={props.openLink}
userScroll={props.userScroll}
@@ -86,11 +89,12 @@ export function MarkdownBlockView(props: {
);
}
-function StyledRuns(props: { runs(): MarkdownRun[]; compact?: boolean }) {
+function StyledRuns(props: { runs(): MarkdownRun[]; sourceId: string; compact?: boolean }) {
return (
<Each of={props.runs()} key={(run) => run.key}>
{(run) => (
<Text
+ textSource={{ id: props.sourceId, segments: run().source }}
style={{
fontWeight: run().strong ? '700' : undefined,
fontStyle: run().emphasis ? 'italic' : undefined,
@@ -109,6 +113,7 @@ function StyledRuns(props: { runs(): MarkdownRun[]; compact?: boolean }) {
function InlineRuns(props: {
runs(): MarkdownRun[];
+ sourceId: string;
compact?: boolean;
openLink(target: string, restoreFocus: () => boolean): Promise<void>;
}) {
@@ -150,7 +155,13 @@ function InlineRuns(props: {
return (
<If
when={group().link}
- else={<StyledRuns runs={() => group().runs} compact={props.compact} />}
+ else={
+ <StyledRuns
+ runs={() => group().runs}
+ sourceId={props.sourceId}
+ compact={props.compact}
+ />
+ }
>
<Text
ref={(value) => {
@@ -164,7 +175,11 @@ function InlineRuns(props: {
onPress={inputAction(activate, { key: group().key, target: group().link!.target })}
style={{ color: C.accentHover, textDecorationLine: 'underline' }}
>
- <StyledRuns runs={() => group().runs} compact={props.compact} />
+ <StyledRuns
+ runs={() => group().runs}
+ sourceId={props.sourceId}
+ compact={props.compact}
+ />
</Text>
</If>
);
@@ -175,6 +190,7 @@ function InlineRuns(props: {
function MarkdownBlockContent(props: {
block: () => MarkdownBlock;
+ sourceId: string;
compact?: boolean;
openLink(target: string, restoreFocus: () => boolean): Promise<void>;
userScroll(): void;
@@ -195,6 +211,7 @@ function MarkdownBlockContent(props: {
else={
<Text
selectable
+ textSource={{ id: props.sourceId, segments: block().source }}
style={{
...(props.compact ? secondaryTextStyle : {}),
color: props.compact ? C.muted : C.text,
@@ -230,6 +247,7 @@ function MarkdownBlockContent(props: {
>
<InlineRuns
runs={() => block().runs}
+ sourceId={props.sourceId}
compact={props.compact}
openLink={props.openLink}
/>
@@ -237,12 +255,18 @@ function MarkdownBlockContent(props: {
</If>
}
>
- <CodeBlock block={block} compact={props.compact} userScroll={props.userScroll} />
+ <CodeBlock
+ block={block}
+ sourceId={props.sourceId}
+ compact={props.compact}
+ userScroll={props.userScroll}
+ />
</If>
}
>
<TableBlock
table={() => block().table!}
+ sourceId={props.sourceId}
compact={props.compact}
openLink={props.openLink}
userScroll={props.userScroll}
@@ -253,6 +277,7 @@ function MarkdownBlockContent(props: {
function TableBlock(props: {
table(): MarkdownTable;
+ sourceId: string;
compact?: boolean;
openLink(target: string, restoreFocus: () => boolean): Promise<void>;
userScroll(): void;
@@ -313,6 +338,7 @@ function TableBlock(props: {
>
<InlineRuns
runs={() => cell().runs}
+ sourceId={props.sourceId}
compact={props.compact}
openLink={props.openLink}
/>
@@ -330,7 +356,12 @@ type CopyState =
| { kind: 'copying' | 'copied'; text: string }
| { kind: 'failed'; text: string; message: string };
-function CodeBlock(props: { block: () => MarkdownBlock; compact?: boolean; userScroll(): void }) {
+function CodeBlock(props: {
+ block: () => MarkdownBlock;
+ sourceId: string;
+ compact?: boolean;
+ userScroll(): void;
+}) {
const block = props.block;
const key = computed(() => block().key);
const text = computed(() => block().text);
@@ -417,6 +448,7 @@ function CodeBlock(props: { block: () => MarkdownBlock; compact?: boolean; userS
<Scroll horizontal={!wrapped()} scrollable={!wrapped()} onScrollIntent={props.userScroll}>
<Text
selectable
+ textSource={{ id: props.sourceId, segments: block().source }}
style={{
...codeTextStyle,
width: wrapped() ? '100%' : undefined,
diff --git a/products/maka/terminal-shell/app/MarkdownBody.tsx b/products/maka/terminal-shell/app/MarkdownBody.tsx
index 2e39ecf893..8f87b38cb1 100644
--- a/products/maka/terminal-shell/app/MarkdownBody.tsx
+++ b/products/maka/terminal-shell/app/MarkdownBody.tsx
@@ -19,6 +19,7 @@
import { errorText, renderText, type UiText } from './text';
import { tr } from './locale';
+import { SourceText } from './SourceText';
import { cleanup, computed, effect, signal, Text, View } from 'nsh';
import { markdown } from 'maka/markdown';
import type { MarkdownBlock } from 'maka/markdown';
@@ -38,6 +39,7 @@ const sameInput = (left: Input | null, right: Input | null) =>
export function MarkdownBody(props: {
row: () => Row;
+ sourceId: string;
compact?: boolean;
openLink(target: string, restoreFocus: () => boolean): Promise<void>;
userScroll(): void;
@@ -120,7 +122,7 @@ export function MarkdownBody(props: {
color: props.compact ? C.muted : C.text,
}}
>
- {props.row().text}
+ <SourceText row={props.row} sourceId={props.sourceId} />
</Text>
<If when={failure()}>
<Text style={{ ...(props.compact ? secondaryTextStyle : {}), color: C.danger }}>
@@ -135,6 +137,7 @@ export function MarkdownBody(props: {
{(block) => (
<MarkdownBlockView
block={block}
+ sourceId={props.sourceId}
compact={props.compact}
openLink={props.openLink}
userScroll={props.userScroll}
diff --git a/products/maka/terminal-shell/app/MessageRow.tsx b/products/maka/terminal-shell/app/MessageRow.tsx
index 87bd87ef61..8a6e2adb28 100644
--- a/products/maka/terminal-shell/app/MessageRow.tsx
+++ b/products/maka/terminal-shell/app/MessageRow.tsx
@@ -24,6 +24,7 @@ import { Button } from 'nsh/base';
import { platform } from 'nsh/device';
import { messageKey, type Row } from './history';
import { MarkdownBody } from './MarkdownBody';
+import { SourceText } from './SourceText';
import { C, bodyTextStyle, secondaryTextStyle, controlStyle } from './ui';
import { Icon } from './Icon';
import { HistoryAttachments } from './Attachments';
@@ -85,6 +86,7 @@ export function MessageRow({
expanded.set(action.value);
if (action.value) onExpand?.(action.key);
};
+ const sourceId = () => `${sessionId}/${row().messageId}/${row().field}`;
const text = computed(() => row().text);
const preview = computed(() => /\S[^\r\n]*/.exec(text())?.[0].trim() ?? '');
const thinking = () => row().field === 'thinking' || row().role === 'thinking';
@@ -117,16 +119,21 @@ export function MessageRow({
when={row().role === 'assistant' && row().field === 'body'}
else={
<Text selectable style={{ color: C.text, ...bodyTextStyle }}>
- {text()}
+ <SourceText row={row} sourceId={sourceId()} />
</Text>
}
>
- <MarkdownBody row={row} openLink={openLink} userScroll={userScroll} />
+ <MarkdownBody
+ row={row}
+ sourceId={sourceId()}
+ openLink={openLink}
+ userScroll={userScroll}
+ />
</If>
}
>
<Text selectable style={{ color: C.text, ...bodyTextStyle }}>
- {text()}
+ <SourceText row={row} sourceId={sourceId()} />
</Text>
</If>
<View
@@ -267,6 +274,7 @@ export function MessageRow({
else={
<MarkdownBody
row={row}
+ sourceId={sourceId()}
openLink={openLink}
userScroll={userScroll}
compact
@@ -274,7 +282,7 @@ export function MessageRow({
}
>
<Text selectable style={{ ...secondaryTextStyle, color: C.muted }}>
- {text()}
+ <SourceText row={row} sourceId={sourceId()} />
</Text>
</If>
</View>
@@ -313,7 +321,7 @@ export function MessageRow({
}}
>
<Text selectable style={{ color: C.text, ...bodyTextStyle }}>
- {text()}
+ <SourceText row={row} sourceId={sourceId()} />
</Text>
<If when={(row().attachments?.length ?? 0) > 0}>
<HistoryAttachments
diff --git a/products/maka/terminal-shell/app/SourceText.tsx b/products/maka/terminal-shell/app/SourceText.tsx
new file mode 100644
index 0000000000..403d041951
--- /dev/null
+++ b/products/maka/terminal-shell/app/SourceText.tsx
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Text } from 'nsh';
+import type { Row } from './history';
+
+/** Canonical history and a live opening address the same original text. */
+export function SourceText(props: { row(): Row; sourceId: string }) {
+ return (
+ <Each
+ of={props.row().fragments ?? [{ offset: 0, text: props.row().text }]}
+ key={(fragment) => fragment.offset}
+ >
+ {(fragment) => (
+ <Text textSource={{ id: props.sourceId, offset: fragment().offset }}>
+ {fragment().text}
+ </Text>
+ )}
+ </Each>
+ );
+}
diff --git a/products/maka/terminal-shell/src/markdown.rs b/products/maka/terminal-shell/src/markdown.rs
index 572e2acc7b..8fd322684d 100644
--- a/products/maka/terminal-shell/src/markdown.rs
+++ b/products/maka/terminal-shell/src/markdown.rs
@@ -23,10 +23,30 @@ use ::markdown::{ParseOptions, mdast::AlignKind, mdast::Node};
use nsh_core::ui::prelude::*;
use std::collections::BTreeMap;
+mod source;
+
+/// A literal UTF-8 interval or one indivisible Markdown transformation.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PropEnum)]
+pub enum MarkdownSourceKind {
+ Literal,
+ Replacement,
+}
+
+/// Local rendered UTF-16 ranges address the original document in UTF-8 bytes.
+#[derive(Clone, Debug, PartialEq, JsObject)]
+pub struct MarkdownSourceSegment {
+ pub start: u32,
+ pub end: u32,
+ pub source_start: u64,
+ pub source_end: u64,
+ pub kind: MarkdownSourceKind,
+}
+
/// Inline content; unsupported constructs retain their complete source spelling.
#[derive(Clone, Debug, PartialEq, JsObject)]
pub struct MarkdownRun {
pub key: String,
+ pub source: Vec<MarkdownSourceSegment>,
pub text: String,
pub strong: bool,
pub emphasis: bool,
@@ -83,6 +103,7 @@ pub struct MarkdownTable {
#[derive(Clone, Debug, PartialEq, JsObject)]
pub struct MarkdownBlock {
pub key: String,
+ pub source: Vec<MarkdownSourceSegment>,
pub kind: MarkdownBlockKind,
pub indent: u32,
pub quote: u32,
@@ -125,7 +146,7 @@ fn parse_source(source: &str) -> Result<Vec<MarkdownBlock>, String> {
references: BTreeMap::new(),
};
output.collect_references(&root);
- output.visit(&root, Context::default());
+ output.visit(&root, Context::default())?;
Ok(output.blocks)
}
@@ -163,11 +184,11 @@ impl Presentation<'_> {
}
}
- fn visit(&mut self, node: &Node, context: Context) {
+ fn visit(&mut self, node: &Node, context: Context) -> Result<(), String> {
match node {
Node::Root(root) => {
for child in &root.children {
- self.visit(child, context);
+ self.visit(child, context)?;
}
}
Node::Blockquote(quote) => {
@@ -178,7 +199,7 @@ impl Presentation<'_> {
quote: context.quote + 1,
..context
},
- );
+ )?;
}
}
Node::List(list) => {
@@ -214,7 +235,7 @@ impl Presentation<'_> {
indent: context.indent + 1,
..context
},
- );
+ )?;
}
if let Some(block) = self.blocks.get_mut(first) {
block.marker = marker;
@@ -228,7 +249,7 @@ impl Presentation<'_> {
MarkdownBlockKind::Paragraph,
0,
context,
- );
+ )?;
}
Node::Heading(heading) => {
self.phrasing(
@@ -237,11 +258,12 @@ impl Presentation<'_> {
MarkdownBlockKind::Heading,
u32::from(heading.depth),
context,
- );
+ )?;
}
Node::Code(code) => {
let mut block = self.block(node, MarkdownBlockKind::Code, context);
block.text.clone_from(&code.value);
+ block.source = source::code(self.source, node, &code.value)?;
block.language = code.lang.clone().unwrap_or_default();
self.blocks.push(block);
}
@@ -273,8 +295,9 @@ impl Presentation<'_> {
child,
Marks::default(),
None,
+ context.quote,
&mut runs,
- );
+ )?;
}
cells.push(MarkdownTableCell {
key: format!("table-cell:{}", start(cell_node)),
@@ -293,12 +316,14 @@ impl Presentation<'_> {
}
let mut block = self.block(node, MarkdownBlockKind::Table, context);
block.text = raw(self.source, node).into();
+ block.source = source::literal(start(node), &block.text);
block.table = Some(MarkdownTable { columns, rows });
self.blocks.push(block);
}
Node::ThematicBreak(_) => {
let mut block = self.block(node, MarkdownBlockKind::Rule, context);
block.text = raw(self.source, node).into();
+ block.source = source::literal(start(node), &block.text);
self.blocks.push(block);
}
// Link definitions are metadata, not rendered document content.
@@ -307,14 +332,17 @@ impl Presentation<'_> {
_ => {
let mut block = self.block(node, MarkdownBlockKind::Literal, context);
block.text = raw(self.source, node).into();
+ block.source = source::literal(start(node), &block.text);
self.blocks.push(block);
}
}
+ Ok(())
}
fn block(&self, node: &Node, kind: MarkdownBlockKind, context: Context) -> MarkdownBlock {
MarkdownBlock {
key: format!("{}:{}", kind.js_name(), start(node)),
+ source: Vec::new(),
kind,
indent: context.indent,
quote: context.quote,
@@ -334,7 +362,7 @@ impl Presentation<'_> {
kind: MarkdownBlockKind,
level: u32,
context: Context,
- ) {
+ ) -> Result<(), String> {
let mut block = self.block(node, kind, context);
for child in children {
inline(
@@ -343,11 +371,13 @@ impl Presentation<'_> {
child,
Marks::default(),
None,
+ context.quote,
&mut block.runs,
- );
+ )?;
}
block.level = level;
self.blocks.push(block);
+ Ok(())
}
}
@@ -365,8 +395,9 @@ fn inline(
node: &Node,
marks: Marks,
link: Option<&MarkdownLink>,
+ quote: u32,
runs: &mut Vec<MarkdownRun>,
-) {
+) -> Result<(), String> {
let linked = match node {
Node::Link(value) => Some((&value.children, &value.url)),
Node::LinkReference(value) => references
@@ -380,9 +411,9 @@ fn inline(
target: target.clone(),
};
for child in children {
- inline(source, references, child, marks, Some(&target), runs);
+ inline(source, references, child, marks, Some(&target), quote, runs)?;
}
- return;
+ return Ok(());
}
let children = match node {
Node::Strong(strong) => Some((
@@ -410,9 +441,9 @@ fn inline(
};
if let Some((children, marks)) = children {
for child in children {
- inline(source, references, child, marks, link, runs);
+ inline(source, references, child, marks, link, quote, runs)?;
}
- return;
+ return Ok(());
}
let (text, marks) = match node {
Node::Text(text) => (&*text.value, marks),
@@ -427,8 +458,14 @@ fn inline(
_ => (raw(source, node), marks),
};
if text.is_empty() {
- return;
+ return Ok(());
}
+ let mut mapped = match node {
+ Node::Text(_) => source::text(source, node, text, quote)?,
+ Node::InlineCode(_) => source::inline_code(source, node, text, quote)?,
+ Node::Break(_) => source::line_break(source, node),
+ _ => source::literal(start(node), text),
+ };
if let Some(previous) = runs.last_mut()
&& previous.strong == marks.strong
&& previous.emphasis == marks.emphasis
@@ -436,11 +473,18 @@ fn inline(
&& previous.code == marks.code
&& previous.link.as_ref() == link
{
+ let shift = previous.source.last().map_or(0, |segment| segment.end);
+ for segment in &mut mapped {
+ segment.start += shift;
+ segment.end += shift;
+ }
+ previous.source.extend(mapped);
previous.text.push_str(text);
- return;
+ return Ok(());
}
runs.push(MarkdownRun {
key: format!("inline:{}", start(node)),
+ source: mapped,
text: text.into(),
strong: marks.strong,
emphasis: marks.emphasis,
@@ -448,6 +492,7 @@ fn inline(
code: marks.code,
link: link.cloned(),
});
+ Ok(())
}
fn start(node: &Node) -> usize {
@@ -513,10 +558,82 @@ mod tests {
assert_eq!(first[0].key, next[0].key);
assert_eq!(first[0].runs[0].key, next[0].runs[0].key);
assert_eq!(first[0].runs[1].text, "粗体");
+ assert_eq!(
+ first[0].runs[0].source,
+ [MarkdownSourceSegment {
+ start: 0,
+ end: 3,
+ source_start: 0,
+ source_end: 7,
+ kind: MarkdownSourceKind::Literal,
+ }]
+ );
+ assert_eq!(
+ first[0].runs[1].source,
+ [MarkdownSourceSegment {
+ start: 0,
+ end: 2,
+ source_start: 9,
+ source_end: 15,
+ kind: MarkdownSourceKind::Literal,
+ }]
+ );
+ assert_eq!(
+ first[0].runs[2].source,
+ [MarkdownSourceSegment {
+ start: 0,
+ end: 11,
+ source_start: 17,
+ source_end: 37,
+ kind: MarkdownSourceKind::Literal,
+ }]
+ );
assert_eq!(first[0].runs[2].text, " 👩🏽💻 e\u{301}");
assert_eq!(next[0].runs[1].key, first[0].runs[1].key);
}
+ #[test]
+ fn source_addresses_keep_entities_escapes_and_inline_code_atomic() {
+ let blocks = parse_source("A & \\* 🙂 ` a🙂 `").unwrap();
+ assert_eq!(blocks[0].runs[0].text, "A & * 🙂 ");
+ let ranges: Vec<_> = blocks[0].runs[0]
+ .source
+ .iter()
+ .map(|segment| {
+ (
+ segment.start,
+ segment.end,
+ segment.source_start,
+ segment.source_end,
+ segment.kind,
+ )
+ })
+ .collect();
+ assert_eq!(
+ ranges,
+ [
+ (0, 2, 0, 2, MarkdownSourceKind::Literal),
+ (2, 3, 2, 7, MarkdownSourceKind::Replacement),
+ (3, 4, 7, 8, MarkdownSourceKind::Literal),
+ (4, 5, 8, 10, MarkdownSourceKind::Replacement),
+ (5, 6, 10, 11, MarkdownSourceKind::Literal),
+ (6, 8, 11, 20, MarkdownSourceKind::Replacement),
+ (8, 9, 20, 21, MarkdownSourceKind::Literal),
+ ]
+ );
+ assert_eq!(blocks[0].runs[1].text, "a🙂");
+ assert_eq!(
+ blocks[0].runs[1].source,
+ [MarkdownSourceSegment {
+ start: 0,
+ end: 3,
+ source_start: 23,
+ source_end: 28,
+ kind: MarkdownSourceKind::Literal,
+ }]
+ );
+ }
+
#[test]
fn tables_keep_columns_rows_empty_cells_and_nested_inline_marks() {
let source = "| 左 **列** | *中* | 右 | 默认 |\n| :--- | :---: | ---: | --- |\n| **粗 *斜*** | ~~删~~ | `码🙂` | |\n| 👩🏽💻 e\u{301} | | [原](https://example.test)  | 尾 |";
diff --git a/products/maka/terminal-shell/src/markdown/source.rs b/products/maka/terminal-shell/src/markdown/source.rs
new file mode 100644
index 0000000000..e5ac5954f4
--- /dev/null
+++ b/products/maka/terminal-shell/src/markdown/source.rs
@@ -0,0 +1,418 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+//! Source addresses for the presentation produced by the existing Markdown AST.
+//! Literal ranges retain exact UTF-8 addresses; lexical replacements are atomic.
+
+use super::{MarkdownSourceKind, MarkdownSourceSegment, raw, start};
+use ::markdown::mdast::Node;
+
+pub(super) fn literal(offset: usize, text: &str) -> Vec<MarkdownSourceSegment> {
+ if text.is_empty() {
+ return Vec::new();
+ }
+ vec![MarkdownSourceSegment {
+ start: 0,
+ end: text.encode_utf16().count() as u32,
+ source_start: offset as u64,
+ source_end: (offset + text.len()) as u64,
+ kind: MarkdownSourceKind::Literal,
+ }]
+}
+
+struct Projection<'a> {
+ expected: &'a str,
+ bytes: usize,
+ units: u32,
+ segments: Vec<MarkdownSourceSegment>,
+}
+
+impl<'a> Projection<'a> {
+ fn new(expected: &'a str) -> Self {
+ Self {
+ expected,
+ bytes: 0,
+ units: 0,
+ segments: Vec::new(),
+ }
+ }
+
+ fn remaining(&self) -> &'a str {
+ &self.expected[self.bytes..]
+ }
+
+ fn push(
+ &mut self,
+ text: &str,
+ source_start: usize,
+ source_end: usize,
+ kind: MarkdownSourceKind,
+ ) -> Result<(), String> {
+ if !self.remaining().starts_with(text) {
+ return Err(format!(
+ "Markdown source projection disagrees with parsed text at byte {source_start}"
+ ));
+ }
+ let end = self.units + text.encode_utf16().count() as u32;
+ if !text.is_empty() {
+ if kind == MarkdownSourceKind::Literal
+ && let Some(previous) = self.segments.last_mut()
+ && previous.kind == kind
+ && previous.source_end == source_start as u64
+ {
+ previous.end = end;
+ previous.source_end = source_end as u64;
+ } else {
+ self.segments.push(MarkdownSourceSegment {
+ start: self.units,
+ end,
+ source_start: source_start as u64,
+ source_end: source_end as u64,
+ kind,
+ });
+ }
+ }
+ self.bytes += text.len();
+ self.units = end;
+ Ok(())
+ }
+
+ fn finish(self) -> Result<Vec<MarkdownSourceSegment>, String> {
+ if self.bytes != self.expected.len() {
+ return Err("Markdown source projection did not cover the parsed text".into());
+ }
+ Ok(self.segments)
+ }
+}
+
+/// A Text leaf can contain escaped punctuation, entities and continuation
+/// container prefixes. Replay those lexical transformations, never search the
+/// document for a rendered substring (repeated text has a different address).
+pub(super) fn text(
+ source: &str,
+ node: &Node,
+ expected: &str,
+ quote: u32,
+) -> Result<Vec<MarkdownSourceSegment>, String> {
+ let input = raw(source, node);
+ if input == expected {
+ return Ok(literal(start(node), input));
+ }
+ let mut result = Projection::new(expected);
+ let mut offset = 0;
+ let mut line_start = false;
+ while offset < input.len() && !result.remaining().is_empty() {
+ let rest = &input[offset..];
+ let base = start(node) + offset;
+ let ch = rest.chars().next().unwrap();
+ if line_start {
+ let consumed = continuation(&mut result, rest, base, quote)?;
+ line_start = false;
+ if consumed != 0 {
+ offset += consumed;
+ continue;
+ }
+ }
+ if ch == '\\'
+ && let Some(escaped) = rest.chars().nth(1)
+ && escaped.is_ascii_punctuation()
+ {
+ let consumed = 1 + escaped.len_utf8();
+ result.push(
+ &rest[1..consumed],
+ base,
+ base + consumed,
+ MarkdownSourceKind::Replacement,
+ )?;
+ offset += consumed;
+ continue;
+ }
+ if ch == '&'
+ && let Some((consumed, decoded)) = reference(rest)
+ && result.remaining().starts_with(&decoded)
+ {
+ result.push(
+ &decoded,
+ base,
+ base + consumed,
+ MarkdownSourceKind::Replacement,
+ )?;
+ offset += consumed;
+ continue;
+ }
+ if ch == '\0' && result.remaining().starts_with('�') {
+ result.push("�", base, base + 1, MarkdownSourceKind::Replacement)?;
+ offset += 1;
+ continue;
+ }
+ if ch == '\r' && result.remaining().starts_with('\n') {
+ let consumed = if rest.starts_with("\r\n") { 2 } else { 1 };
+ result.push("\n", base, base + consumed, MarkdownSourceKind::Replacement)?;
+ offset += consumed;
+ line_start = true;
+ continue;
+ }
+ result.push(
+ &rest[..ch.len_utf8()],
+ base,
+ base + ch.len_utf8(),
+ MarkdownSourceKind::Literal,
+ )?;
+ offset += ch.len_utf8();
+ if matches!(ch, '\n' | '\r') {
+ line_start = true;
+ }
+ }
+ result.finish()
+}
+
+fn reference(input: &str) -> Option<(usize, String)> {
+ let end = 1 + input.as_bytes()[1..]
+ .iter()
+ .position(|byte| !byte.is_ascii_alphanumeric() && *byte != b'#')?;
+ if input.as_bytes()[end] != b';' {
+ return None;
+ }
+ let name = &input[1..end];
+ let decoded = if let Some(number) = name.strip_prefix("#x").or_else(|| name.strip_prefix("#X"))
+ {
+ (!number.is_empty() && number.bytes().all(|byte| byte.is_ascii_hexdigit()))
+ .then(|| ::markdown::decode_numeric(number, 16))?
+ } else if let Some(number) = name.strip_prefix('#') {
+ (!number.is_empty() && number.bytes().all(|byte| byte.is_ascii_digit()))
+ .then(|| ::markdown::decode_numeric(number, 10))?
+ } else {
+ ::markdown::decode_named(name, true)?
+ };
+ Some((end + 1, decoded))
+}
+
+pub(super) fn inline_code(
+ source: &str,
+ node: &Node,
+ expected: &str,
+ quote: u32,
+) -> Result<Vec<MarkdownSourceSegment>, String> {
+ let input = raw(source, node);
+ let fence = input.bytes().take_while(|byte| *byte == b'`').count();
+ let mut first = fence;
+ let mut last = input.len() - fence;
+ let body = &input[first..last];
+ // CommonMark removes one surrounding space, except for all-space code.
+ if body.starts_with(' ') && body.ends_with(' ') && body.chars().any(|ch| ch != ' ') {
+ first += 1;
+ last -= 1;
+ }
+ if &input[first..last] == expected {
+ return Ok(literal(start(node) + first, expected));
+ }
+ let mut result = Projection::new(expected);
+ let mut offset = first;
+ let mut line_start = false;
+ while offset < last {
+ let rest = &input[offset..last];
+ let ch = rest.chars().next().unwrap();
+ let base = start(node) + offset;
+ if line_start {
+ let consumed = continuation(&mut result, rest, base, quote)?;
+ line_start = false;
+ if consumed != 0 {
+ offset += consumed;
+ continue;
+ }
+ }
+ let consumed = if rest.starts_with("\r\n") {
+ 2
+ } else {
+ ch.len_utf8()
+ };
+ if matches!(ch, '\r' | '\n') && result.remaining().starts_with(' ') {
+ result.push(" ", base, base + consumed, MarkdownSourceKind::Replacement)?;
+ } else if rest.starts_with("\\|") && result.remaining().starts_with('|') {
+ result.push("|", base, base + 2, MarkdownSourceKind::Replacement)?;
+ offset += 2;
+ continue;
+ } else {
+ result.push(
+ &rest[..consumed],
+ base,
+ base + consumed,
+ MarkdownSourceKind::Literal,
+ )?;
+ }
+ offset += consumed;
+ line_start = matches!(ch, '\r' | '\n');
+ }
+ result.finish()
+}
+
+/// Container syntax and leading whitespace form one indentation token. Any
+/// whitespace retained by the parser belongs to that token, rather than to a
+/// guessed occurrence among several identical source spaces.
+fn continuation(
+ result: &mut Projection<'_>,
+ input: &str,
+ base: usize,
+ quote: u32,
+) -> Result<usize, String> {
+ let mut quotes = 0;
+ let mut consumed = 0;
+ for ch in input.chars() {
+ if matches!(ch, ' ' | '\t') || (ch == '>' && quotes < quote) {
+ if ch == '>' {
+ quotes += 1;
+ }
+ consumed += ch.len_utf8();
+ } else {
+ break;
+ }
+ }
+ if consumed != 0 {
+ let remaining = result.remaining();
+ let end = remaining.len() - remaining.trim_start_matches([' ', '\t']).len();
+ result.push(
+ &remaining[..end],
+ base,
+ base + consumed,
+ MarkdownSourceKind::Replacement,
+ )?;
+ }
+ Ok(consumed)
+}
+
+pub(super) fn line_break(source: &str, node: &Node) -> Vec<MarkdownSourceSegment> {
+ vec![MarkdownSourceSegment {
+ start: 0,
+ end: 1,
+ source_start: start(node) as u64,
+ source_end: (start(node) + raw(source, node).len()) as u64,
+ kind: MarkdownSourceKind::Replacement,
+ }]
+}
+
+/// The parser's code value consists of source lines with their container,
+/// indentation and fence removed. Match only that line's exact retained suffix;
+/// the discarded prefix must consist of container/indentation characters.
+pub(super) fn code(
+ source: &str,
+ node: &Node,
+ expected: &str,
+) -> Result<Vec<MarkdownSourceSegment>, String> {
+ let input = raw(source, node);
+ let mut source_offset = 0;
+ let first_line = &input[..line_end(input)];
+ let fence = first_line.trim_start_matches([' ', '\t']);
+ let fenced = fence.starts_with("```") || fence.starts_with("~~~");
+ if fenced {
+ source_offset += first_line.len();
+ }
+ let mut result = Projection::new(expected);
+ while !result.remaining().is_empty() {
+ let rest = &input[source_offset..];
+ let end = line_end(rest);
+ if end == 0 {
+ return Err("Markdown code source ended before its parsed value".into());
+ }
+ let source_line = &rest[..end];
+ let content = source_line.trim_end_matches(['\r', '\n']);
+ let rendered = result.remaining();
+ let rendered_end = rendered.find(['\r', '\n']).unwrap_or(rendered.len());
+ let rendered_line = &rendered[..rendered_end];
+ let normalized = content.replace('\0', "�");
+ let (prefix, leading) = if let Some(prefix) = normalized.strip_suffix(rendered_line) {
+ (prefix, "")
+ } else {
+ // A tab consumed partly by indentation can leave rendered spaces.
+ // Keep that indentation as one lexical replacement, never the code.
+ let tail = rendered_line.trim_start_matches([' ', '\t']);
+ let prefix = normalized
+ .strip_suffix(tail)
+ .ok_or("Markdown code line is not an exact source suffix")?;
+ (prefix, &rendered_line[..rendered_line.len() - tail.len()])
+ };
+ if !prefix.chars().all(|ch| matches!(ch, ' ' | '\t' | '>')) {
+ return Err("Markdown code discarded a non-container source prefix".into());
+ }
+ let base = start(node) + source_offset;
+ if !leading.is_empty() {
+ result.push(
+ leading,
+ base,
+ base + prefix.len(),
+ MarkdownSourceKind::Replacement,
+ )?;
+ }
+ let kept = &content[prefix.len()..];
+ if !kept.contains('\0') {
+ result.push(
+ kept,
+ base + prefix.len(),
+ base + content.len(),
+ MarkdownSourceKind::Literal,
+ )?;
+ } else {
+ for (at, ch) in kept.char_indices() {
+ let base = base + prefix.len() + at;
+ if ch == '\0' && result.remaining().starts_with('�') {
+ result.push("�", base, base + 1, MarkdownSourceKind::Replacement)?;
+ } else {
+ let text = &source[base..base + ch.len_utf8()];
+ result.push(
+ text,
+ base,
+ base + ch.len_utf8(),
+ MarkdownSourceKind::Literal,
+ )?;
+ }
+ }
+ }
+ if rendered_end < rendered.len() {
+ let ending = if rendered[rendered_end..].starts_with("\r\n") {
+ "\r\n"
+ } else {
+ &rendered[rendered_end..rendered_end + 1]
+ };
+ let raw_ending = &source_line[content.len()..];
+ let kind = if raw_ending == ending {
+ MarkdownSourceKind::Literal
+ } else {
+ MarkdownSourceKind::Replacement
+ };
+ let base = start(node) + source_offset + content.len();
+ result.push(
+ ending,
+ base,
+ start(node) + source_offset + source_line.len(),
+ kind,
+ )?;
+ }
+ source_offset += source_line.len();
+ }
+ result.finish()
+}
+
+fn line_end(source: &str) -> usize {
+ source.find(['\r', '\n']).map_or(source.len(), |at| {
+ at + if source[at..].starts_with("\r\n") {
+ 2
+ } else {
+ 1
+ }
+ })
+}
diff --git a/products/maka/terminal-shell/tests/real_host.rs b/products/maka/terminal-shell/tests/real_host.rs
index 9084c69fa3..1065197ac2 100644
--- a/products/maka/terminal-shell/tests/real_host.rs
+++ b/products/maka/terminal-shell/tests/real_host.rs
@@ -1896,6 +1896,11 @@ async fn real_agent_history_and_live_stream_render_and_the_app_sends_once() {
support::evidence_cells("history-home-wheel-final-page-published", &cancelled);
assert_eq!(retained.lines().position(|line| line.contains(&wheel_marker)), Some(wheel_row), "a completed, cancelled Home moved the user's wheel anchor:\n{retained}");
assert!(!retained.contains("History question 00"), "the cancelled Home snapped to canonical start:\n{retained}");
+ let boundary_tail = format!("History {boundary_turn:02} paragraph 29:");
+ let formatted = wait_view_state(&mut cancelled, |view, _| view.accessibility_snapshot().iter().any(|node| node.label.starts_with(&boundary_tail) && !node.label.contains("**bold**"))).await;
+ assert_eq!(formatted.lines().position(|line| line.contains(&wheel_marker)), Some(wheel_row), "formatting the newly completed prefix moved the same original reading anchor:\n{formatted}");
+ assert!(!formatted.contains("History question 00"), "formatting restored an obsolete Home target:\n{formatted}");
+ evidence("history-home-wheel-after-formatting", &formatted);
support::wait_draft(&cancel_client, &fixture.session, HOME_DRAFT).await;
assert_eq!(client.query("agent", "observe", json!({"sessionId":fixture.session})).await.unwrap()["through"], observed["through"], "history navigation must not change the Host range");
evidence("history-home-wheel-cancels-late-reply", &retained);
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org