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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ for GitHub Release notes, so every published version must have a matching

### Added

- Link nested typed JSON `tool_result`/`tool_response` objects to earlier tool
calls by exact ID, alongside the existing direct tool-role result shape.
- Add `r` in interactive JSONL/NDJSON views to toggle an exact, bounded raw
record snapshot. Follow refresh and attached/detached viewport state continue
behind the snapshot, and `r` returns to the structured view.
- Add `-F`/`--follow` for interactive JSONL/NDJSON files. Follow mode opens at
the committed tail without formatting the whole file, advances on appended
records while attached, detaches when scrolled up, reattaches at the bottom,
Expand All @@ -21,6 +26,10 @@ for GitHub Release notes, so every published version must have a matching

### Changed

- Collapse high-confidence inline base64 media in interactive JSONL/NDJSON
views to media type and validated decoded size without decoding or copying it
into the formatted display spool. Source records, redirected output, diffs,
tool arguments, media bytes, and unknown fields remain token-preserving.
- Split the publishable, terminal-independent viewer engine into the new
`fmtview-core` workspace crate. Format profiles and transforms, load/index
models, file and diff viewer state, search/navigation, highlighting, layout,
Expand Down
54 changes: 51 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,16 @@ embedded markup, wrapped records, or formatted diffs.
marks the object start, and its colored guide covers the body between the
opening and closing braces. Boundary rows and objects without a direct role
stay neutral. Role-bearing objects are also preferred for structure jumps.
- Match direct `role: tool` results to recent earlier tool calls by exact ID in
common `tool_call_id`/`tool_use_id` and contextual custom fields. Matched
- Match direct `role: tool` results and nested typed `tool_result` objects to
recent earlier tool calls by exact ID in common `tool_call_id`/`tool_use_id`,
`call_id`, and contextual custom fields. Matched
calls and results reuse the existing line-number separator for compact
`↓`/`↑` direction markers, without taking another column; press `t` to jump
exactly between a matched pair.
- Collapse high-confidence inline base64 media in the interactive JSONL view to
a media type and validated decoded byte size, without allocating a decoded
payload. Press `r` to inspect the current record's original source text and
press it again to return to the structured view.
- Preserve data semantics. JSON strings are highlighted for readability, not
rewritten.
- Keep large outputs responsive by indexing a temporary text file and only
Expand Down Expand Up @@ -240,6 +245,26 @@ Other types are intentionally passthrough:
- Jinja templates are indexed and highlighted as templates, but `fmtview` does
not render them, evaluate includes, or rewrite template statements.

### Conversation records and inline media

Conversation-shaped JSON remains ordinary JSON: direct `role`/`ref` fields,
typed `content` items, reasoning, runtime reminders, artifact metadata, and
unknown future fields are formatted and displayed instead of projected into a
fixed schema. Typed `tool_call`/`tool_use` and `tool_result`/`tool_response`
objects can link by exact IDs even when they are nested in `content` arrays.
Embedded argument strings keep their original JSON token spelling and escapes.

In the interactive JSONL viewer, an unescaped-ASCII
`data:<media-type>;base64,...` string is shown as a compact media summary. A
same-object `type: base64` or `type: base64url` plus
`media_type`/`mime_type` and later `data` field is handled too, as is a direct
`attachment` object under a typed image item. Standard and URL-safe alphabets
are validated separately. Metadata must precede that sibling `data` field for
streaming recognition, and arbitrary `media_type` plus `data` objects are not
guessed to be base64. Valid payloads show the decoded byte size; recognized
invalid payloads say `invalid base64` instead of claiming a size. Redirected
output never collapses these strings.

### Following growing JSONL files

Use `-F`/`--follow` with a JSONL or NDJSON file to open directly at its current
Expand Down Expand Up @@ -319,6 +344,7 @@ The repository includes small sample files that exercise the viewer features:
fmtview examples/showcase.json
fmtview examples/events.jsonl
fmtview examples/chat.jsonl
fmtview examples/conversation.jsonl
fmtview examples/long-inline.jsonl
fmtview examples/response.xml
fmtview examples/page.html
Expand All @@ -345,6 +371,9 @@ useful for testing structure jumps around partially observed inline content.
mixed `system`/`user`/`assistant`/`tool` ordering, an object without a role, and
a matched tool call/result pair for testing role scopes, relation markers,
pair navigation, and chat-aware structure jumps.
`examples/conversation.jsonl` includes generic typed content, nested tool
call/result objects, an exact embedded arguments string, reasoning/runtime and
artifact metadata, and same-object base64 media.
`examples/page.html` is well-formed HTML that also works as XML-compatible
markup.
`examples/messy.html` is loose HTML with void elements, unclosed optional
Expand Down Expand Up @@ -376,6 +405,7 @@ Shift+Wheel horizontal scroll in nowrap mode
n/N next/previous search match
]/[ next/previous structure
t jump between the focused tool call/result pair
r toggle current JSONL record between structured and raw source view
m toggle mouse selection mode
Digits+Enter jump to a line number, for example 1200 Enter
Backspace edit a pending prompt
Expand Down Expand Up @@ -425,6 +455,18 @@ With `--follow`, this same indexed spool starts from the committed tail, loads
older records backward when needed, and extends forward after source refreshes.
The footer shows `follow:on`, `follow:detached`, or `follow:off`.

Press `r` on a JSONL/NDJSON record to open a bounded raw snapshot backed by its
exact immutable raw-spool range. An active search match selects its containing
record; otherwise the viewport's top record is used. The snapshot stays on that
record even if the source is appended, truncated, or replaced in the
background; returning to structured mode shows the updated stream without
changing an existing detached anchor. Raw display
uses 32 KiB visual chunks so a huge record is never copied wholesale into the
viewer frame. Search works within those chunks, but a query spanning an
artificial chunk boundary is not matched. Invalid UTF-8 is displayed lossily;
the exact bytes remain in the source/spool and are never reconstructed from
pretty output. Press `q` (or an idle `Esc`) to quit from either view.

To jump to a specific line, type the line number directly and press Enter. While
a line jump is pending, the footer shows the target line; Backspace edits it and
Esc cancels it. Out-of-range line numbers are clamped to the file. On fully
Expand Down Expand Up @@ -452,7 +494,8 @@ line numbers.

Tool calls and tool results reuse the existing line-number separator instead
of adding a separate relation lane. The matcher only considers ID-like fields
in a tool call object or a direct `"role": "tool"` result object, then requires
in a tool call object, a direct `"role": "tool"` result object, or a nested
typed `tool_result`/`tool_response` object, then requires
an exact ID value match with an earlier call from bounded recent context. On a
cold jump, context recovery reads at most 4,096 preceding lines. A matched call
replaces `│` with `↓`, and its result replaces it with `↑`; the
Expand Down Expand Up @@ -543,6 +586,11 @@ rendered output in memory for browsing.
- Lazy preview writes transformed records into a temporary spool and keeps compact
offsets, not formatted strings, in memory. The title shows `N+` lines while
the session index is still incomplete and idle time extends the index.
- JSONL raw toggles write only ingested source records to a separate immutable
temporary spool, so an open snapshot cannot change after source replacement.
- Viewer-only JSONL formatting scans recognized base64 payloads in buffered
slices, validates padding/alphabet, and writes only the media summary. It does
not allocate a decoded buffer or a payload-sized formatted output buffer.
- Passthrough inputs, such as Markdown, TOML, plain text, and Jinja templates,
are indexed without content rewriting.
- The terminal viewer uses compact ANSI redraws and avoids repainting invisible
Expand Down
16 changes: 16 additions & 0 deletions crates/fmtview-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,22 @@ The core viewer owns viewport-anchor preservation and follow state through
backend-neutral events, including `ViewerCommand::FollowTail` and
`ViewerCommand::ToggleFollowTail`.

Record-backed views can expose exact raw-record snapshots through bounded
virtual lines. Lazy JSONL records are copied once at ingest into an immutable
raw spool, so opening raw mode is an offset lookup and source replacement
cannot change the snapshot. `FileViewer` owns the `r` structured/raw toggle,
separate raw search and wrap state, and synchronization when returning to the
structured viewport. Follow refresh and the main viewport's attached or
detached state continue behind an open raw snapshot.

Generic conversation handling also stays in core. The JSON package recognizes
direct chat roles, contextual tool calls, direct tool-role results, and nested
typed tool results without depending on an application's storage types. The
viewer-only JSONL display path can collapse explicitly typed inline base64
media to media type and validated decoded size without decoding the payload.
Normal transforms preserve exact tool-argument tokens, media payloads, and
unknown fields for redirected output and diffs.

Reset overlap uses a bounded, non-consuming probe of the committed source
prefix. Exact IDs for the matched prefix are filtered from tail and later older
batches, so large replacements remain tail-first without guessing from equal
Expand Down
103 changes: 85 additions & 18 deletions crates/fmtview-core/src/formats/json/tool_links.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ struct ToolContainer {
start_line: usize,
role_tool: bool,
toolish_type: bool,
tool_result_type: bool,
contains_typed_result: bool,
ids: Vec<IdCandidate>,
provisional_link: Option<ToolLink>,
pending_child: Option<ContainerContext>,
Expand Down Expand Up @@ -88,7 +90,7 @@ struct PendingCall {
#[derive(Debug, Clone)]
struct ToolDiscovery {
start_line: usize,
link: ToolLink,
link: Option<ToolLink>,
}

#[derive(Debug)]
Expand All @@ -108,7 +110,7 @@ enum ToolMatchDecision {

impl ToolLinkTracker {
pub(crate) fn apply_line(&mut self, line: &str, line_number: usize) {
self.scan_line(line, line_number);
let _ = self.scan_line(line, line_number);
}

#[cfg(test)]
Expand All @@ -128,7 +130,11 @@ impl ToolLinkTracker {
for (offset, line) in visible_lines.iter().chain(lookahead_lines).enumerate() {
let line_number = first_line.saturating_add(offset);
for ToolDiscovery { start_line, link } in self.scan_line(line, line_number) {
results.insert(start_line, (offset, link));
if let Some(link) = link {
results.insert(start_line, (offset, link));
} else {
results.remove(&start_line);
}
}
if let Some((start_line, link)) = self.active_result() {
results.insert(start_line, (offset, link.clone()));
Expand Down Expand Up @@ -189,12 +195,10 @@ impl ToolLinkTracker {
'{' => self.push_container(ContainerKind::Object, line_number),
'[' => self.push_container(ContainerKind::Array, line_number),
'}' => {
if let Some(discovery) = self.pop_container(ContainerKind::Object) {
discoveries.push(discovery);
}
discoveries.extend(self.pop_container(ContainerKind::Object));
}
']' => {
self.pop_container(ContainerKind::Array);
discoveries.extend(self.pop_container(ContainerKind::Array));
}
_ => {}
}
Expand Down Expand Up @@ -223,24 +227,49 @@ impl ToolLinkTracker {
start_line: line_number,
role_tool: false,
toolish_type: false,
tool_result_type: false,
contains_typed_result: false,
ids: Vec::new(),
provisional_link: None,
pending_child: None,
});
}

fn pop_container(&mut self, expected: ContainerKind) -> Option<ToolDiscovery> {
fn pop_container(&mut self, expected: ContainerKind) -> Vec<ToolDiscovery> {
let mut discoveries = Vec::new();
while let Some(container) = self.containers.pop() {
if container.kind != expected {
continue;
}
if expected == ContainerKind::Object {
if container.role_tool {
if (container.role_tool || container.tool_result_type)
&& !container.contains_typed_result
{
let link = self.link_result(container.start_line, &container.ids);
return link.map(|link| ToolDiscovery {
start_line: container.start_line,
link,
});
let mut preserved_ancestor_fallback = false;
if container.tool_result_type {
let authoritative_child_id = container
.ids
.iter()
.any(|candidate| result_id_rank(&candidate.key) >= 3);
let preserve_explicit_fallback = !authoritative_child_id
&& link
.as_ref()
.is_none_or(|link| link.status != ToolLinkStatus::Matched);
preserved_ancestor_fallback = self.suppress_ancestor_results(
&mut discoveries,
preserve_explicit_fallback,
);
}
if let Some(link) = link
&& !(preserved_ancestor_fallback && link.status != ToolLinkStatus::Matched)
{
discoveries.push(ToolDiscovery {
start_line: container.start_line,
link: Some(link),
});
}
return discoveries;
}
if (container.context == ContainerContext::ToolCallObject || container.toolish_type)
&& !container.ids.is_empty()
Expand All @@ -252,12 +281,40 @@ impl ToolLinkTracker {
while self.pending_calls.len() > MAX_PENDING_CALLS {
self.pending_calls.pop_front();
}
return None;
return discoveries;
}
}
break;
}
None
discoveries
}

fn suppress_ancestor_results(
&mut self,
discoveries: &mut Vec<ToolDiscovery>,
preserve_explicit_fallback: bool,
) -> bool {
let mut preserved = false;
for parent in &mut self.containers {
if preserve_explicit_fallback
&& (parent.role_tool || parent.tool_result_type)
&& parent
.ids
.iter()
.any(|candidate| result_id_rank(&candidate.key) >= 3)
{
preserved = true;
continue;
}
parent.contains_typed_result = true;
if parent.provisional_link.take().is_some() {
discoveries.push(ToolDiscovery {
start_line: parent.start_line,
link: None,
});
}
}
preserved
}

fn apply_property(&mut self, property: JsonProperty) {
Expand All @@ -268,7 +325,10 @@ impl ToolLinkTracker {
if let Some(value) = property.string_value {
match key.as_str() {
"role" => container.role_tool = value.eq_ignore_ascii_case("tool"),
"type" => container.toolish_type = is_tool_call_type(&value),
"type" => {
container.toolish_type = is_tool_call_type(&value);
container.tool_result_type = is_tool_result_type(&value);
}
_ if is_id_key(&key)
&& key.len() <= MAX_TOOL_ID_KEY_BYTES
&& value.len() <= MAX_TOOL_ID_BYTES =>
Expand Down Expand Up @@ -297,8 +357,8 @@ impl ToolLinkTracker {
let Some(container) = self.containers.last() else {
return;
};
let link = container
.role_tool
let link = ((container.role_tool || container.tool_result_type)
&& !container.contains_typed_result)
.then(|| self.preview_result(container.start_line, &container.ids))
.flatten();
if let Some(container) = self.containers.last_mut() {
Expand Down Expand Up @@ -533,6 +593,13 @@ fn is_tool_call_type(value: &str) -> bool {
)
}

fn is_tool_result_type(value: &str) -> bool {
matches!(
value.to_ascii_lowercase().as_str(),
"tool_result" | "tool_response" | "function_result" | "function_response"
)
}

fn child_context(key: &str) -> Option<ContainerContext> {
let lower = key.to_ascii_lowercase();
if matches!(
Expand Down
Loading