dsh-design-studio 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/core.js CHANGED
@@ -16,7 +16,9 @@ const LOCAL_URL = /^(?:https?:\/\/)?(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]):
16
16
  // 命令输出里的本机地址(开发服务器启动时会打印)
17
17
  const URL_IN_TEXT = /\bhttps?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]):(\d{2,5})(\/[^\s"'`<>)\]]*)?/gi
18
18
  const LIVE_MS = 120000 // 事件时间离现在超过这么久就当是重放历史
19
+ const CHANGE_LOG = 20 // 记最近几批文件改动(远程看按它补齐两次轮询之间的改动)
19
20
  const DESIGN_SKILL = 'ui-design'
21
+ const ASK_TOOL = 'ask_user_question' // DSH 的提问卡:模型问了、在等用户回答
20
22
  const DESIGN_META = /<meta[^>]+name=["']?dsh-design\b/i
21
23
  // 只查看、不启动服务的命令(curl 一下别的项目的端口,不该把那个端口当成在做的设计)
22
24
  const PROBE_CMD = /\b(curl|wget|httpie|xh|lsof|netstat|ss|nc|telnet|kill|pkill|open|ps)\b/i
@@ -64,7 +66,8 @@ export function listScreens(root) {
64
66
  try { dirents = fs.readdirSync(root, { withFileTypes: true }) } catch { return [] }
65
67
  const out = []
66
68
  for (const d of dirents) {
67
- if (!d.isFile() || !HTML_EXT.test(d.name) || d.name.startsWith('.')) continue
69
+ // . 开头是隐藏文件,_ 开头当草稿 / 模型的临时探针(_probe.html、_layout.html),都不算一屏
70
+ if (!d.isFile() || !HTML_EXT.test(d.name) || d.name.startsWith('.') || d.name.startsWith('_')) continue
68
71
  let born = 0
69
72
  try {
70
73
  const st = fs.statSync(path.join(root, d.name))
@@ -267,6 +270,176 @@ export function replaceText(html, before, after, nth = 0) {
267
270
  return { ok: false, reason: 'not-found' }
268
271
  }
269
272
 
273
+ // ── 预览里调样式:在 HTML 源码里找到那个元素的开头标签,把改的样式并进它的 style 属性 ─────────
274
+
275
+ /** 开头标签(属性值里可以有 >);注释整块跳过,脚本 / 样式 / 模板这类连内容一起跳过(模板里的元素不在页面上;<SCRIPT> 大写也算) */
276
+ const TAG_SCAN = /<!--[\s\S]*?-->|<(script|style|textarea|title|template)\b(?:[^>"']|"[^"]*"|'[^']*')*>[\s\S]*?<\/\1\s*>|<([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/gi
277
+ const ATTR = /([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g
278
+ /** 能调的几项;值只许数字、单位、颜色、var(--x) 这类,引号、分号、尖括号一律不收 */
279
+ export const STYLE_PROPS = ['font-size', 'font-weight', 'color', 'background', 'border-radius', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'gap']
280
+ const STYLE_VALUE = /^[\w\s#%.,()+-]{1,80}$/
281
+
282
+ function attrsOf(s) {
283
+ const out = {}
284
+ ATTR.lastIndex = 0
285
+ for (let m = ATTR.exec(s); m; m = ATTR.exec(s)) {
286
+ const k = m[1].toLowerCase()
287
+ if (!(k in out)) out[k] = { v: m[2] ?? m[3] ?? m[4] ?? '', at: m.index, len: m[0].length }
288
+ }
289
+ return out
290
+ }
291
+
292
+ /**
293
+ * 页面上那个元素在源码里是哪个开头标签。loc:{ tag, n(页面上第几个这种标签), total(页面上一共几个), id, cls(页面上的类) }。
294
+ * 页面上和源码里这种标签一样多:按第几个找,再核对 id 和类(页面脚本可能加了类,源码里的类都在就行);
295
+ * 对不上(页面脚本加过元素):只认 id 和类完全一样、而且只有一个的。都不行就算找不到,宁可交给模型也别改错地方。
296
+ */
297
+ export function findTag(html, { tag, n = 0, total = -1, id = '', cls = [] } = {}) {
298
+ const name = String(tag || '').toLowerCase()
299
+ if (!/^[a-z][\w-]*$/.test(name)) return null
300
+ const list = []
301
+ TAG_SCAN.lastIndex = 0
302
+ for (let m = TAG_SCAN.exec(html); m; m = TAG_SCAN.exec(html)) {
303
+ if (m[1] || !m[2] || m[2].toLowerCase() !== name) continue
304
+ list.push({ start: m.index, end: m.index + m[0].length, name: m[2], attrs: m[3] || '' })
305
+ }
306
+ const want = new Set((Array.isArray(cls) ? cls : []).map(String))
307
+ const classesOf = (x) => { const a = attrsOf(x.attrs); return { id: a.id ? a.id.v : '', cls: a.class ? a.class.v.split(/\s+/).filter(Boolean) : [] } }
308
+ const same = (x) => { const a = classesOf(x); return a.id === String(id || '') && a.cls.length === want.size && a.cls.every((k) => want.has(k)) }
309
+ const x = list[n]
310
+ if (x && total === list.length) {
311
+ const a = classesOf(x)
312
+ if (a.id === String(id || '') && a.cls.every((k) => want.has(k))) return x
313
+ }
314
+ const hits = list.filter(same)
315
+ return hits.length === 1 ? hits[0] : null
316
+ }
317
+
318
+ /** style 属性按 ; 拆成一条条(括号、引号里的不拆,比如 url(data:…;base64,…)) */
319
+ function splitDecls(s) {
320
+ const out = []
321
+ let depth = 0
322
+ let q = ''
323
+ let cur = ''
324
+ for (const ch of s) {
325
+ if (q) { if (ch === q) q = ''; cur += ch; continue }
326
+ if (ch === '"' || ch === "'") q = ch
327
+ else if (ch === '(') depth++
328
+ else if (ch === ')') depth = Math.max(0, depth - 1)
329
+ else if (ch === ';' && !depth) { out.push(cur); cur = ''; continue }
330
+ cur += ch
331
+ }
332
+ out.push(cur)
333
+ return out.map((d) => d.trim()).filter(Boolean)
334
+ }
335
+
336
+ /**
337
+ * 把 css(属性 → 值,值为空 = 去掉这一条)并进原来的 style(已解开实体的文字):改过的原地换(同名的几条只留一条),新的放最后;
338
+ * 写 background 时去掉 background-color / -image(不然前后打架)。自定义属性(--x)区分大小写,原样留着。
339
+ */
340
+ export function mergeStyle(style, css) {
341
+ const decls = splitDecls(String(style || '')).map((d) => {
342
+ const i = d.indexOf(':')
343
+ if (i <= 0) return null
344
+ const p = d.slice(0, i).trim()
345
+ return [p.startsWith('--') ? p : p.toLowerCase(), d.slice(i + 1).trim()]
346
+ }).filter(Boolean)
347
+ for (const [k, v] of Object.entries(css || {})) {
348
+ const prop = k.toLowerCase()
349
+ if (prop === 'background') for (let i = decls.length - 1; i >= 0; i--) if (/^background-(color|image)$/.test(decls[i][0])) decls.splice(i, 1)
350
+ const i = decls.findIndex(([p]) => p === prop)
351
+ for (let j = decls.length - 1; j > i && i >= 0; j--) if (decls[j][0] === prop) decls.splice(j, 1)
352
+ if (!v) { if (i >= 0) decls.splice(i, 1); continue }
353
+ if (i >= 0) decls[i][1] = v
354
+ else decls.push([prop, v])
355
+ }
356
+ return decls.map(([p, v]) => `${p}: ${v}`).join('; ')
357
+ }
358
+
359
+ /** 属性值里的实体解回来(style 里用得到的:引号、&、尖括号) */
360
+ const unAttr = (s) => String(s)
361
+ .replace(/&(quot|#0*34|#x0*22);/gi, '"').replace(/&(apos|#0*39|#x0*27);/gi, "'")
362
+ .replace(/&(lt|#0*60|#x0*3c);/gi, '<').replace(/&(gt|#0*62|#x0*3e);/gi, '>').replace(/&(amp|#0*38|#x0*26);/gi, '&')
363
+
364
+ /** 只留能调的那几项、值合规的;一项都不剩返回 null */
365
+ export function cleanCss(css) {
366
+ const out = {}
367
+ for (const [k, v] of Object.entries(css && typeof css === 'object' ? css : {})) {
368
+ const prop = String(k).toLowerCase()
369
+ const val = String(v ?? '').trim()
370
+ if (!STYLE_PROPS.includes(prop)) continue
371
+ if (val && (!STYLE_VALUE.test(val) || /url\s*\(|expression/i.test(val))) continue
372
+ out[prop] = val
373
+ }
374
+ return Object.keys(out).length ? out : null
375
+ }
376
+
377
+ /** 选择器写法统一:去引号、:: → :、空白压成一个、组合符和括号两边不留空格 */
378
+ const normSel = (s) => String(s || '').replace(/["']/g, '').replace(/::/g, ':').replace(/\s+/g, ' ').replace(/ ?([>+~,()]) ?/g, '$1').trim()
379
+
380
+ /**
381
+ * 一条样式规则在源码(.css,或 HTML 里的 <style>)的第几行。
382
+ * sel 是浏览器给的(规范化过:组合符两边加空格、属性值加双引号),源码里可能写得紧凑、换了引号、多了换行:都认(大小写照原样比)。
383
+ * 不用拼正则:逐个字符找规则开头的「{」,把它前面那段选择器统一写法后比较,一遍扫完。
384
+ * n:同样选择器的第几条(源码顺序,@media 里的也算)。找不到给 0
385
+ */
386
+ export function findRule(text, sel, n = 0) {
387
+ const want = normSel(sel)
388
+ if (!want || want.length > 400) return 0
389
+ const src = String(text || '').replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' ')) // 注释换成空格(位置、行号不变)
390
+ // HTML 只看 <style> 里面(脚本、正文里像 CSS 的字不算)
391
+ const regions = []
392
+ if (/<style\b/i.test(src)) {
393
+ const re = /<style\b[^>]*>/gi
394
+ let m
395
+ while ((m = re.exec(src))) {
396
+ const from = m.index + m[0].length
397
+ const end = src.slice(from).search(/<\/style\s*>/i)
398
+ regions.push([from, end < 0 ? src.length : from + end])
399
+ re.lastIndex = end < 0 ? src.length : from + end
400
+ }
401
+ } else regions.push([0, src.length])
402
+ let k = 0
403
+ for (const [from, to] of regions) {
404
+ let start = from
405
+ for (let i = from; i < to; i++) {
406
+ const ch = src[i]
407
+ if (ch === '"' || ch === "'") { // 字符串里的 { } ; 不算
408
+ let j = i + 1
409
+ while (j < to && src[j] !== ch && src[j] !== '\n') j += src[j] === '\\' ? 2 : 1
410
+ i = j
411
+ } else if (ch === '{') {
412
+ const pre = src.slice(start, i)
413
+ if (normSel(pre) === want && k++ === n) {
414
+ const at = start + (pre.length - pre.trimStart().length)
415
+ return src.slice(0, at).split('\n').length
416
+ }
417
+ start = i + 1
418
+ } else if (ch === '}' || ch === ';') start = i + 1
419
+ }
420
+ }
421
+ return 0
422
+ }
423
+
424
+ /**
425
+ * 把 css 写进页面上那个元素(loc,见 findTag)在源码里的 style 属性。
426
+ * @returns {{ ok: true, html } | { ok: false, reason: 'empty' | 'not-found' }}
427
+ */
428
+ export function setStyle(html, loc, css) {
429
+ const clean = cleanCss(css)
430
+ if (!clean) return { ok: false, reason: 'empty' }
431
+ const x = findTag(html, loc)
432
+ if (!x) return { ok: false, reason: 'not-found' }
433
+ const a = attrsOf(x.attrs).style
434
+ const next = mergeStyle(a ? unAttr(a.v) : '', clean).replace(/&/g, '&amp;').replace(/"/g, '&quot;')
435
+ let attrs
436
+ if (a) attrs = x.attrs.slice(0, a.at) + (next ? `style="${next}"` : '') + x.attrs.slice(a.at + a.len)
437
+ // 自闭合的 / 前面得是空白或引号(src=a.png/ 里的 / 是属性值的一部分)
438
+ else if (next) attrs = /(^|["'\s])\/\s*$/.test(x.attrs) ? x.attrs.replace(/\s*\/\s*$/, ` style="${next}" /`) : x.attrs + ` style="${next}"`
439
+ else attrs = x.attrs
440
+ return { ok: true, html: html.slice(0, x.start) + `<${x.name}${attrs}>` + html.slice(x.end) }
441
+ }
442
+
270
443
  export function serverUrlIn(text, skipPorts = new Set()) {
271
444
  let found = null
272
445
  URL_IN_TEXT.lastIndex = 0
@@ -291,6 +464,8 @@ export function serverUrlIn(text, skipPorts = new Set()) {
291
464
  * openRev 每次要右侧栏自动弹出来就 +1(客户端看到变大就打开标签页)
292
465
  * rev 页面内容变了就 +1(文件改动)
293
466
  * turn 这个会话正在跑一轮(预览上显示「正在生成」)
467
+ * turns 这次运行里干完了几轮(右侧栏看它变了就自动体检)
468
+ * asking 模型用提问卡问了用户、在等回答(右侧栏不转圈,提示去对话里回答)
294
469
  *
295
470
  * @param opts.open (target) => Promise<{ origin }> 开预览服务器
296
471
  * @param opts.skipPorts () => Set<number> 不当开发服务器的端口(DSH 自己、预览服务器)
@@ -401,15 +576,21 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
401
576
  return cwds.get(id) || os.homedir()
402
577
  }
403
578
 
404
- /** 新的一屏排到最后;「再出两版」的 login-b.html / login-c.html 紧跟在 login.html 后面,方便挑 */
579
+ /**
580
+ * 新的一屏排到最后;同一屏的几个版本挨在一起、按字母排,方便挑:
581
+ * 「再出两版」的 login-b.html / login-c.html 紧跟在 login.html 后面;开局的三个方向 home-a / home-b / home-c 哪个先写完都排成 a、b、c
582
+ */
405
583
  function placeScreen(list, k) {
406
- const VAR = /^(.+)-[b-z]\.html?$/
407
- const stem = (x) => x.toLowerCase().replace(/\.html?$/, '')
408
- const m = VAR.exec(k.toLowerCase())
409
- if (m && list.some((x) => stem(x) === m[1])) {
410
- let at = -1
411
- list.forEach((x, i) => { if (stem(x) === m[1] || (VAR.exec(x.toLowerCase()) || [])[1] === m[1]) at = i })
412
- list.splice(at + 1, 0, k)
584
+ const VAR = /^(.+)-([a-z])\.html?$/
585
+ const part = (x) => {
586
+ const v = VAR.exec(x.toLowerCase())
587
+ return v ? { stem: v[1], letter: v[2] } : { stem: x.toLowerCase().replace(/\.html?$/, ''), letter: '' }
588
+ }
589
+ const me = part(k)
590
+ const group = me.letter ? list.map((x, i) => ({ i, ...part(x) })).filter((x) => x.stem === me.stem) : []
591
+ if (group.length) {
592
+ const after = group.find((x) => x.letter > me.letter)
593
+ list.splice(after ? after.i : group[group.length - 1].i + 1, 0, k)
413
594
  return
414
595
  }
415
596
  list.push(k)
@@ -504,12 +685,18 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
504
685
  persist(id, st)
505
686
  }
506
687
 
507
- /** 预览服务器报告某个目录里有文件改了:用到这个目录的会话都 rev+1 */
508
- function changed(root) {
688
+ /** 预览服务器报告某个目录里有文件改了:用到这个目录的会话都 rev+1(files:改了哪些,远程看的右侧栏按它决定哪几屏重新打包) */
689
+ function changed(root, files) {
509
690
  for (const [id, st] of states) {
510
691
  if (st.target && st.target.kind === 'file' && st.target.root === root) {
511
692
  st.rev++
512
693
  st.changedAt = now()
694
+ // 文件太多(预览服务器那边已经截断了)就不列:空的 = 不知道改了啥,右侧栏整屏刷新
695
+ const list = Array.isArray(files) ? files.filter((f) => typeof f === 'string') : []
696
+ st.changedFiles = list.length > 50 ? [] : list
697
+ // 最近几批改动(递增序号):远程看的右侧栏两次轮询之间可能来了好几批,按序号取并集,别只看最后一批
698
+ st.changeN = (st.changeN || 0) + 1
699
+ st.changes = [...(st.changes || []), { n: st.changeN, files: st.changedFiles }].slice(-CHANGE_LOG)
513
700
  const before = st.screens.join('\n')
514
701
  refreshScreens(st)
515
702
  if (st.screens.join('\n') !== before) persist(id, st)
@@ -543,7 +730,11 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
543
730
  case 'turn/end':
544
731
  st.turn = false
545
732
  st.writing = ''
546
- if (live) keepVersion(st, st.ask)
733
+ st.asking = false
734
+ if (live) {
735
+ keepVersion(st, st.ask)
736
+ st.turns = (st.turns || 0) + 1 // 右侧栏靠它知道「又干完一轮」(很快的一轮轮询看不到 turn 变 true)
737
+ }
547
738
  st.ask = '' // 用过就清:下一轮不是用户发起的(没有新的一句话)就不借这一句当名字
548
739
  break
549
740
  case 'tool/call': {
@@ -551,6 +742,7 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
551
742
  if (d.name === 'skill' && argsOf(d.arguments).name === DESIGN_SKILL) st.design = true
552
743
  if (d.name === 'preview_page') st.design = true
553
744
  if (!live) break
745
+ if (d.name === ASK_TOOL) st.asking = true
554
746
  if (d.callId) {
555
747
  st.calls.set(d.callId, { name: d.name, args: argsOf(d.arguments) })
556
748
  if (st.calls.size > 64) st.calls.delete(st.calls.keys().next().value)
@@ -575,6 +767,7 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
575
767
  const callId = (d.message && (d.message.toolCallId || (d.message.source && d.message.source.callId))) || d.callId || ''
576
768
  const call = callId ? st.calls.get(callId) : null
577
769
  if (callId) st.calls.delete(callId)
770
+ if (!call || call.name === ASK_TOOL) st.asking = false
578
771
  if (pending && fs.existsSync(pending) && (!st.target || st.source === 'auto') && !(st.target && st.target.file === pending)) {
579
772
  // 只有在做设计(加载了设计技能、或页面写了设计类型标记)才自动打开;别的任务写的网页只记下来,不弹
580
773
  // 用户关了「自动打开预览」:也只记下来,右侧栏给个按钮
@@ -623,16 +816,32 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
623
816
  return v
624
817
  }
625
818
 
819
+ /** 预览里直接改字:在这一屏(key = 文件名)的 HTML 里把第 nth 处 before 换成 after,改前改后各存一版 */
820
+ function editText(id, { key, before, after, nth = 0 } = {}) {
821
+ const short = String(after).replace(/\s+/g, ' ').trim()
822
+ return rewrite(id, key, (html) => replaceText(html, before, after, Math.max(0, Math.floor(Number(nth) || 0))),
823
+ `改字「${short.length > 16 ? short.slice(0, 16) + '…' : short}」`)
824
+ }
825
+
826
+ /** 预览里调样式:把 css 写进这一屏(key)里那个元素(loc,见 findTag)的 style 属性,改前改后各存一版 */
827
+ function editStyle(id, { key, loc, css, name } = {}) {
828
+ const short = String(name || (loc && loc.tag) || '').trim()
829
+ return rewrite(id, key, (html) => setStyle(html, loc || {}, css), `调样式「${short.length > 24 ? short.slice(0, 24) + '…' : short}」`)
830
+ }
831
+
626
832
  /**
627
- * 预览里直接改字:在这一屏(key = 文件名)的 HTML 里把第 nth 处 before 换成 after,改前改后各存一版。
833
+ * 改这一屏(key = 文件名)的 HTML:fn(原文) → { ok, html }。
628
834
  * 开发服务器的页面源码在项目里、不一定是这份 HTML,不在这里改(右侧栏改成让模型去改)。
629
- * 模型正在干活时不改:它可能正整份重写这个文件,改了也会被盖掉(右侧栏改成让模型去改)。
835
+ * 模型正在干活时不改:它可能正整份重写这个文件,改了也会被盖掉。
630
836
  */
631
- function editText(id, { key, before, after, nth = 0 } = {}) {
837
+ function rewrite(id, key, fn, label) {
632
838
  const st = states.get(id)
633
839
  const t = st && st.target
634
840
  if (!t || t.kind !== 'file') return { ok: false, reason: 'not-file' }
635
- if (st.turn) return { ok: false, reason: 'busy' }
841
+ // 同一个目录别的会话的模型正在干活也不改(它可能正整份重写这个文件);经软链接打开的同一个目录也算
842
+ const realOf = (p) => { try { return fs.realpathSync(p) } catch { return p } }
843
+ const mine = realOf(t.root)
844
+ for (const o of states.values()) if (o.turn && o.target && o.target.kind === 'file' && (o.target.root === t.root || realOf(o.target.root) === mine)) return { ok: false, reason: 'busy' }
636
845
  const name = String(key || path.basename(t.file))
637
846
  const file = path.join(t.root, name)
638
847
  if (path.dirname(file) !== t.root || !HTML_EXT.test(name) || !fs.existsSync(file)) return { ok: false, reason: 'no-screen' }
@@ -640,12 +849,11 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
640
849
  let real = ''
641
850
  try { real = fs.realpathSync(file) } catch { real = '' }
642
851
  if (!real.startsWith(fs.realpathSync(t.root) + path.sep)) return { ok: false, reason: 'no-screen' }
643
- const r = replaceText(fs.readFileSync(file, 'utf8'), before, after, Math.max(0, Math.floor(Number(nth) || 0)))
852
+ const r = fn(fs.readFileSync(file, 'utf8'))
644
853
  if (!r.ok) return r
645
854
  keepVersion(st, '')
646
855
  fs.writeFileSync(file, r.html)
647
- const short = String(after).replace(/\s+/g, ' ').trim()
648
- keepVersion(st, `改字「${short.length > 16 ? short.slice(0, 16) + '…' : short}」`)
856
+ keepVersion(st, label)
649
857
  return { ok: true, file: name }
650
858
  }
651
859
 
@@ -669,7 +877,7 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
669
877
  const st = states.get(id)
670
878
  if (!(st && st.target)) restore(id)
671
879
  else if (st.origin && !alive(st.origin)) revive(id, st)
672
- if (!st || !st.target) return { target: null, rev: st ? st.rev : 0, openRev: st ? st.openRev : 0, turn: st ? st.turn : false, writing: '', hint: st && st.hint && fs.existsSync(st.hint) ? st.hint : '', loading: restoring.has(id) }
880
+ if (!st || !st.target) return { target: null, rev: st ? st.rev : 0, openRev: st ? st.openRev : 0, turn: st ? st.turn : false, turns: st ? st.turns || 0 : 0, asking: !!(st && st.asking), writing: '', hint: st && st.hint && fs.existsSync(st.hint) ? st.hint : '', loading: restoring.has(id) }
673
881
  const { target, url, label, source, rev, openRev, turn, writing, changedAt, kind, size, view, origin } = st
674
882
  const file = target.kind === 'file'
675
883
  return {
@@ -680,8 +888,12 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
680
888
  rev,
681
889
  openRev,
682
890
  turn,
891
+ turns: st.turns || 0,
892
+ asking: !!st.asking,
683
893
  writing,
684
894
  changedAt,
895
+ changedFiles: st.changedFiles || [],
896
+ changes: st.changes || [],
685
897
  kind,
686
898
  size,
687
899
  view,
@@ -696,5 +908,5 @@ export function createTracker({ open, skipPorts = () => new Set(), now = Date.no
696
908
  }
697
909
  }
698
910
 
699
- return { observe, setTarget, setView, clear, changed, snapshot, cwdOf, stateOf, versions, restoreVersion, editText, keepVersion: (id, label) => keepVersion(stateOf(id), label) }
911
+ return { observe, setTarget, setView, clear, changed, snapshot, cwdOf, stateOf, versions, restoreVersion, editText, editStyle, keepVersion: (id, label) => keepVersion(stateOf(id), label) }
700
912
  }