Files
kilocode/packages/ui
sylwester-liljegren 8013e5f504 feat(ui): parse and render clickable file links (#11219)
* feat(ui): parse file-path candidates with line and column suffixes

Replace the regex-based parseFilePath with extractSuffix and
normalizeCandidatePath helpers, and make extractFilePathFromHref return
structured { path, line, column } data instead of a bare string.

This shifts inline-code detection from "does this string look like a
file?" to "extract a candidate path so the filesystem can confirm it
later", which also handles :line / :line:col and :start-end line ranges,
a/ and b/ diff prefixes, and Windows drive paths. Unit tests are
rewritten to cover the new helpers and the structured href result.

* feat(ui): render and open clickable file links

Mark every inline code span as a file-link candidate during markdown
rendering, then validate candidates against the filesystem after render
and promote confirmed files to clickable links. Results are cached so
morphdom re-renders during streaming resolve without extra round-trips,
and unconfirmed candidates fall back to plain code.

Clicking a confirmed link (or a markdown link whose href is a file path)
opens the file at the referenced line/column. Markdown file-path links
also get distinct styling.

* fix(ui): tighten file-link detection and streaming validation

Address kilo-code-bot review on #11219:
- preserve top-level a/ and b/ directories instead of treating them as diff
  prefixes for every candidate
- only treat markdown hrefs as file targets when the href still looks like a
  file path after removing query/fragment and parsing line suffixes
- observe text and file-link attribute mutations during streaming so morphdom
  in-place updates re-run validation

* fix(ui): preserve common extensionless file hrefs

Address kilo-code-bot review on #11219: keep the href guard that avoids
routing extensionless docs links through openFile, but allow common
extensionless workspace files (LICENSE, Dockerfile, Makefile) to use the
file-open path. Mirror the helper in kilo-ui and cover the allowlist plus
unknown extensionless rejection in file-path tests.

* fix(ui): scope file-link validation to session and harden against races

Addresses review feedback on PR #11219 (marius-kilocode):

- validateFiles requests now carry an explicit sessionID; the extension
  resolves the workspace directory from that id instead of its own
  mutable currentSession, so a session switch mid-request can't validate
  candidates against the wrong worktree.
- Introduce a module-level file-link-validator singleton shared by every
  TextPartDisplay instance: in-flight requests for the same path are
  coalesced instead of duplicated, and the validation cache lives outside
  component state so virtualization unmount/remount no longer discards it
  and repeats filesystem checks for unchanged history.
- Bound filesystem check concurrency in file-links.ts (mapLimit, cap 8)
  instead of firing an unbounded Promise.all per validateFiles call.
- Guard DOM promotion against stale responses: only mutate a candidate
  element if it's still connected and still represents the path that was
  validated, so a slow response can't clobber a newer candidate that
  reused the same node during streaming.
- A validateFiles timeout now rejects instead of resolving with [], and
  is retried with backoff before giving up as an unconfirmed (not
  negative) result, so it's never cached as "file doesn't exist".

* test(ui): update bidi contract test for file-link candidate markup

The codespan renderer added by this PR marks non-trivial inline code
spans as file-link candidates, so the plain <code dir="auto"> markup
this test expected no longer matches. Update the expectation to the
candidate-wrapped output while still asserting dir="auto" is preserved.

* refactor(ui): harden and de-duplicate clickable file-link validation

Follow-up review fixes on PR #11219:

- Escape the `data-file-candidate` attribute value in the codespan
  renderer. It's derived from raw model output, so a stray `"` could
  break out of the attribute (neutralized by the DOMPurify pass, but now
  fixed at generation time for correctness + defense-in-depth). Adds
  escapeAttribute() to the shared file-path module.
- Only treat code spans that look like file paths (an extension or a
  known extensionless name) as candidates, so bare identifiers like
  `useState`/`null` no longer trigger filesystem probes during
  streaming. Exposes looksLikeFilePath() for this.
- De-duplicate file-path.ts: expose it via the `@opencode-ai/ui/file-path`
  export and import it from kilo-ui instead of keeping a byte-identical
  copy with dead exports. Deletes packages/kilo-ui/src/file-path.ts.
- Keep target/rel on file-path markdown links so the shared opencode
  consumer's navigation/security behavior is unchanged; Kilo's click
  handler still intercepts via preventDefault.
- Cache negative validation results with a short TTL so a file created
  mid-session becomes clickable on the next scan (positives never
  expire). Retry/TTL are now tunable via checkFile options.
- Filter the MutationObserver so promote()'s own class/attribute writes
  no longer schedule an extra no-op validation pass.
- Add unit tests for looksLikeFilePath, escapeAttribute, the codespan
  candidate/escaping behavior, and the validator's dedup/batching/cache/
  TTL/retry paths.

* fix(ui): scope file-link open to its session and harden rendering

Thread an explicit sessionID through the click-to-open path (OpenFileFn,
openFile webview message, editor-actions) so opening a validated file link
resolves against the session it was rendered for, mirroring validateFiles and
avoiding the wrong worktree during an Agent Manager session switch.

Also: re-scan file-link candidates once streaming stops so candidates left
'unknown' after a mid-stream validation timeout get a final pass; make the
validator cache LRU (refresh recency on read) instead of FIFO; and escape
href/title in the markdown link renderer as defense-in-depth alongside
DOMPurify.

* feat(ui): make extensionless and bare-token file references clickable

The candidate heuristic previously required a dotted basename or one of three hardcoded extensionless names (Dockerfile/LICENSE/Makefile), so real files like 'install', 'run-script', or 'configure' were never validated and stayed plain.

Split the heuristic: keep looksLikeFilePath strict for markdown-link hrefs (decided statically, no filesystem check, so a false positive would hijack a doc route), and add a permissive looksLikeCandidate for inline code spans, which ARE validated against the filesystem afterward. A code span now qualifies unless it is empty or contains code punctuation, so extensionless files and separator paths become clickable while expressions like useState() stay plain.

To keep the broader candidate set from multiplying probes, defer candidate validation until a message finishes streaming instead of running it on every morphdom frame; completed history still validates on mount.

* refactor(ui): isolate file-link negative cache from confirmed positives

Addresses kilo-code-bot's suggestion on the widened looksLikeCandidate heuristic: with nearly every bare token now probed, transient negatives (useState, null, ...) dominated the single fixed-size LRU and could evict confirmed-file entries, forcing re-probes on revisit.

Split the validator cache into a positives Set (confirmed files, never expire) and a negatives Map (TTL'd), each LRU-bounded independently (2000 / 1000). Negative-probe churn now only evicts other negatives, a real clickable file is never pushed out by non-file noise, and eviction stays O(1) with no scan.

* feat(ui): validate file links during streaming with a per-element settle debounce

Replaces the defer-until-complete pass with incremental validation so links appear while a message streams (requested in review), without the partial-path probe storm that defer avoided.

Because morphdom keeps the same <code> node as a healed streamed path grows (src/fo -> src/foo.ts), each candidate carries a per-element debounce: a probe fires only after that element's path has been stable for 400ms, and re-arms whenever the path changes, so intermediate partials are superseded before hitting the filesystem. Settled candidates behind the streaming frontier light up mid-stream; on completion all pending candidates flush immediately. Combined with the split positive/negative caches, a growing token now costs one probe for its final value instead of one per frame.

---------

Co-authored-by: Sylwester Liljegren <sylwester.liljegren@softronic.se>
2026-08-13 13:59:13 +02:00
..
2026-07-29 02:06:01 +02:00
2025-11-22 10:39:25 -05:00
2026-08-06 00:00:45 +02:00
2026-07-29 02:06:01 +02:00
2026-07-22 13:31:23 +02:00
2026-02-26 16:05:04 -06:00