opencode-extended-sidebar 0.2.90

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.
Files changed (105) hide show
  1. package/.oesignore +17 -0
  2. package/CHANGELOG.md +95 -0
  3. package/LICENSE +21 -0
  4. package/README.md +235 -0
  5. package/oes.json +11 -0
  6. package/package.json +100 -0
  7. package/scripts/install-git-hooks.mjs +29 -0
  8. package/src/pware.oc.core/constants/index.ts +18 -0
  9. package/src/pware.oc.core/constants/pware.oc.core.constants.eventKind.ts +29 -0
  10. package/src/pware.oc.core/constants/pware.oc.core.constants.eventName.ts +13 -0
  11. package/src/pware.oc.core/constants/pware.oc.core.constants.eventType.ts +241 -0
  12. package/src/pware.oc.core/constants/pware.oc.core.constants.myWork.ts +55 -0
  13. package/src/pware.oc.core/constants/pware.oc.core.constants.partType.ts +65 -0
  14. package/src/pware.oc.core/constants/pware.oc.core.constants.phase.ts +58 -0
  15. package/src/pware.oc.core/constants/pware.oc.core.constants.pulse.ts +63 -0
  16. package/src/pware.oc.core/constants/pware.oc.core.constants.rowKind.ts +34 -0
  17. package/src/pware.oc.core/constants/pware.oc.core.constants.status.ts +74 -0
  18. package/src/pware.oc.core/constants/pware.oc.core.constants.toolName.ts +69 -0
  19. package/src/pware.oc.core/git/index.ts +7 -0
  20. package/src/pware.oc.core/git/pware.oc.core.git.ts +237 -0
  21. package/src/pware.oc.core/git/pware.oc.core.gitignore.ts +87 -0
  22. package/src/pware.oc.core/index.ts +18 -0
  23. package/src/pware.oc.core/pware.oc.core.bus.ts +42 -0
  24. package/src/pware.oc.core/pware.oc.core.cache.ts +48 -0
  25. package/src/pware.oc.core/pware.oc.core.clipboard.ts +97 -0
  26. package/src/pware.oc.core/pware.oc.core.debug.ts +280 -0
  27. package/src/pware.oc.core/pware.oc.core.events.ts +79 -0
  28. package/src/pware.oc.core/pware.oc.core.glyph.ts +135 -0
  29. package/src/pware.oc.core/pware.oc.core.layout.ts +234 -0
  30. package/src/pware.oc.core/pware.oc.core.oes.ts +89 -0
  31. package/src/pware.oc.core/pware.oc.core.paths.ts +240 -0
  32. package/src/pware.oc.core/pware.oc.core.preview.ts +74 -0
  33. package/src/pware.oc.core/pware.oc.core.pulse.ts +726 -0
  34. package/src/pware.oc.core/pware.oc.core.sqlite.ts +175 -0
  35. package/src/pware.oc.core/pware.oc.core.status.ts +214 -0
  36. package/src/pware.oc.core/pware.oc.core.timing.ts +63 -0
  37. package/src/pware.oc.core/pware.oc.core.width.ts +215 -0
  38. package/src/pware.oc.omo/constants/index.ts +15 -0
  39. package/src/pware.oc.omo/constants/pware.oc.omo.constants.backgroundTask.ts +18 -0
  40. package/src/pware.oc.omo/constants/pware.oc.omo.constants.boulderStatus.ts +49 -0
  41. package/src/pware.oc.omo/constants/pware.oc.omo.constants.docKind.ts +29 -0
  42. package/src/pware.oc.omo/constants/pware.oc.omo.constants.eventName.ts +11 -0
  43. package/src/pware.oc.omo/constants/pware.oc.omo.constants.planStatus.ts +60 -0
  44. package/src/pware.oc.omo/constants/pware.oc.omo.constants.reviewStatus.ts +49 -0
  45. package/src/pware.oc.omo/constants/pware.oc.omo.constants.startWork.ts +25 -0
  46. package/src/pware.oc.omo/constants/pware.oc.omo.constants.verdict.ts +38 -0
  47. package/src/pware.oc.omo/index.ts +7 -0
  48. package/src/pware.oc.omo/resolver/index.ts +19 -0
  49. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approval.ts +179 -0
  50. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approvalGroup.ts +116 -0
  51. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approvalState.ts +34 -0
  52. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.boulder.ts +503 -0
  53. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.config.ts +40 -0
  54. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.doc.ts +269 -0
  55. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.draftFile.ts +18 -0
  56. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.notepadsFile.ts +18 -0
  57. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.plan.ts +155 -0
  58. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.planFile.ts +361 -0
  59. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.proofFile.ts +18 -0
  60. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.rulesFile.ts +11 -0
  61. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.runContinuationFile.ts +14 -0
  62. package/src/pware.oc.opencode/constants/index.ts +10 -0
  63. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.eventName.ts +15 -0
  64. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.fileTouch.ts +18 -0
  65. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.questionKind.ts +45 -0
  66. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.sessionStatus.ts +58 -0
  67. package/src/pware.oc.opencode/index.ts +8 -0
  68. package/src/pware.oc.opencode/pware.oc.opencode.events.ts +182 -0
  69. package/src/pware.oc.opencode/pware.oc.opencode.files.ts +399 -0
  70. package/src/pware.oc.opencode/resolver/index.ts +170 -0
  71. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.file.ts +162 -0
  72. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.question.ts +185 -0
  73. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.session.ts +211 -0
  74. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.todo.ts +26 -0
  75. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.tool.ts +236 -0
  76. package/src/pware.oc.perf/index.ts +14 -0
  77. package/src/pware.oc.perf/pware.oc.perf.asciichart.d.ts +15 -0
  78. package/src/pware.oc.perf/pware.oc.perf.charts.ts +284 -0
  79. package/src/pware.oc.perf/pware.oc.perf.reader.ts +927 -0
  80. package/src/pware.oc.perf/pware.oc.perf.realtime.ts +88 -0
  81. package/src/pware.oc.perf/pware.oc.perf.realtimeBlock.ts +118 -0
  82. package/src/pware.oc.perf/pware.oc.perf.realtimeCpuRam.ts +79 -0
  83. package/src/pware.oc.perf/pware.oc.perf.realtimeSampler.ts +71 -0
  84. package/src/pware.oc.perf/pware.oc.perf.realtimeTimeline.ts +214 -0
  85. package/src/pware.oc.perf/pware.oc.perf.self.ts +179 -0
  86. package/src/pware.oc.perf/pware.oc.perf.view.tsx +462 -0
  87. package/src/pware.oc.runtime/index.ts +13 -0
  88. package/src/pware.oc.runtime/pware.oc.runtime.monitor.ts +142 -0
  89. package/src/pware.oc.runtime/pware.oc.runtime.mywork-enrich.ts +79 -0
  90. package/src/pware.oc.runtime/pware.oc.runtime.mywork.ts +253 -0
  91. package/src/pware.oc.runtime/pware.oc.runtime.questions.ts +95 -0
  92. package/src/pware.oc.runtime/pware.oc.runtime.snapshotClient.ts +129 -0
  93. package/src/pware.oc.runtime/pware.oc.runtime.source.ts +105 -0
  94. package/src/pware.oc.runtime/pware.oc.runtime.worker.ts +43 -0
  95. package/src/pware.oc.runtime/resolver/index.ts +224 -0
  96. package/src/pware.oc.runtime/resolver/pware.oc.runtime.resolver.delegate.ts +146 -0
  97. package/src/pware.oc.ui/index.ts +12 -0
  98. package/src/pware.oc.ui/pware.oc.ui.chrome.tsx +339 -0
  99. package/src/pware.oc.ui/pware.oc.ui.glyphs.tsx +144 -0
  100. package/src/pware.oc.ui/pware.oc.ui.host.tsx +111 -0
  101. package/src/pware.oc.ui/pware.oc.ui.live.tsx +82 -0
  102. package/src/pware.oc.ui/pware.oc.ui.menudialogs.tsx +843 -0
  103. package/src/pware.oc.ui/pware.oc.ui.sections.tsx +526 -0
  104. package/src/pware.oc.ui/pware.oc.ui.sidebar.tsx +1629 -0
  105. package/src/pware.oc.ui.tsx +98 -0
@@ -0,0 +1,182 @@
1
+ import {
2
+ deltaKindFromEvent,
3
+ deltaTextFromEvent,
4
+ estimateTokens,
5
+ flowFromEvent,
6
+ sessionBusyFromEvent,
7
+ sessionIdFromEvent,
8
+ toolHitFromEvent,
9
+ type FlowHint,
10
+ type ToolHit,
11
+ } from "../pware.oc.core/pware.oc.core.pulse.js"
12
+ import { eventType } from "../pware.oc.core/pware.oc.core.events.js"
13
+ import {
14
+ fileFilter,
15
+ filesFromEvent,
16
+ type FileView,
17
+ } from "./pware.oc.opencode.files.js"
18
+ import {
19
+ EV_OC_FILES_TOUCHED,
20
+ EV_OC_FLOW,
21
+ EV_OC_SESSION_ACTIVITY,
22
+ EV_OC_TOKENS_DELTA,
23
+ EV_OC_TOOL_HIT,
24
+ } from "./constants/pware.oc.opencode.constants.eventName.js"
25
+
26
+ export type OcSessionActivityEvent = {
27
+ type: typeof EV_OC_SESSION_ACTIVITY
28
+ ts: number
29
+ data: {
30
+ sessionId: string | null
31
+ busy: boolean
32
+ }
33
+ }
34
+
35
+ export type OcFlowEvent = {
36
+ type: typeof EV_OC_FLOW
37
+ ts: number
38
+ data: {
39
+ sessionId: string
40
+ dir: FlowHint
41
+ }
42
+ }
43
+
44
+ export type OcToolHitEvent = {
45
+ type: typeof EV_OC_TOOL_HIT
46
+ ts: number
47
+ data: {
48
+ hit: ToolHit
49
+ }
50
+ }
51
+
52
+ export type OcFilesTouchedEvent = {
53
+ type: typeof EV_OC_FILES_TOUCHED
54
+ ts: number
55
+ data: {
56
+ sessionId: string
57
+ files: FileView[]
58
+ }
59
+ }
60
+
61
+ export type OcTokensDeltaEvent = {
62
+ type: typeof EV_OC_TOKENS_DELTA
63
+ ts: number
64
+ data: {
65
+ sessionId: string
66
+ /** Which stream produced the delta — `out` (text) or `reasoning` (thinking). */
67
+ kind: "out" | "reasoning"
68
+ tokens: number
69
+ }
70
+ }
71
+
72
+ export type OcEvent =
73
+ | OcSessionActivityEvent
74
+ | OcFlowEvent
75
+ | OcToolHitEvent
76
+ | OcFilesTouchedEvent
77
+ | OcTokensDeltaEvent
78
+
79
+ export function hostEventToOcEvents(
80
+ evt: unknown,
81
+ opts: {
82
+ sessionId: string
83
+ projectRoot: string | null
84
+ },
85
+ ): OcEvent[] {
86
+ const ts = Date.now()
87
+ const out: OcEvent[] = []
88
+ const eventSessionId = sessionIdFromEvent(evt) ?? opts.sessionId
89
+
90
+ const busy = sessionBusyFromEvent(evt)
91
+ if (busy.busy != null) {
92
+ out.push({
93
+ type: EV_OC_SESSION_ACTIVITY,
94
+ ts,
95
+ data: {
96
+ sessionId: busy.id ?? eventSessionId,
97
+ busy: busy.busy,
98
+ },
99
+ })
100
+ }
101
+
102
+ const flow = flowFromEvent(evt)
103
+ const flowSessionId = flow.id ?? eventSessionId
104
+ if (flow.dir && flowSessionId) {
105
+ out.push({
106
+ type: EV_OC_FLOW,
107
+ ts,
108
+ data: {
109
+ sessionId: flowSessionId,
110
+ dir: flow.dir,
111
+ },
112
+ })
113
+ }
114
+
115
+ const hit = toolHitFromEvent(evt)
116
+ if (hit && (!hit.sessionId || hit.sessionId === opts.sessionId)) {
117
+ out.push({
118
+ type: EV_OC_TOOL_HIT,
119
+ ts,
120
+ data: { hit },
121
+ })
122
+ }
123
+
124
+ const files = filesFromEvent(evt, opts.sessionId, fileFilter(opts.projectRoot))
125
+ if (files.length > 0) {
126
+ out.push({
127
+ type: EV_OC_FILES_TOUCHED,
128
+ ts,
129
+ data: {
130
+ sessionId: eventSessionId,
131
+ files,
132
+ },
133
+ })
134
+ }
135
+
136
+ const delta = deltaTextFromEvent(evt)
137
+ if (delta) {
138
+ const tokens = estimateTokens(delta)
139
+ if (tokens > 0) {
140
+ out.push({
141
+ type: EV_OC_TOKENS_DELTA,
142
+ ts,
143
+ data: {
144
+ sessionId: eventSessionId,
145
+ kind: deltaKindFromEvent(evt),
146
+ tokens,
147
+ },
148
+ })
149
+ }
150
+ }
151
+
152
+ return out
153
+ }
154
+
155
+ const QUESTION_EVENT_HINTS = ["question.asked", "question.replied", "question.rejected"] as const
156
+
157
+ function isQuestionToolBag(bag: unknown): boolean {
158
+ if (!bag || typeof bag !== "object") return false
159
+ const b = bag as Record<string, unknown>
160
+ const tool = b.tool
161
+ if (typeof tool !== "string" || tool.toLowerCase() !== "question") return false
162
+ const type = b.type
163
+ return type == null || String(type).toLowerCase() === "tool"
164
+ }
165
+
166
+ /** Session id when a host event signals a `question` tool part changed state, else `null`. */
167
+ export function questionSessionFromEvent(evt: unknown): string | null {
168
+ const type = eventType(evt)
169
+ if (QUESTION_EVENT_HINTS.some((hint) => type.includes(hint))) {
170
+ return sessionIdFromEvent(evt)
171
+ }
172
+ if (!evt || typeof evt !== "object") return null
173
+ const o = evt as Record<string, unknown>
174
+ const props =
175
+ o.properties && typeof o.properties === "object"
176
+ ? (o.properties as Record<string, unknown>)
177
+ : null
178
+ for (const bag of [o.part, props?.part, props]) {
179
+ if (isQuestionToolBag(bag)) return sessionIdFromEvent(evt)
180
+ }
181
+ return null
182
+ }
@@ -0,0 +1,399 @@
1
+ /**
2
+ * Session file hits: basename + diff stats only.
3
+ * Never keeps path, before/after, or tool I/O.
4
+ */
5
+ import { readGitMarksFor, relToGitRoot, type GitLetter } from "../pware.oc.core/git/pware.oc.core.git.js"
6
+ import { profile } from "../pware.oc.core/pware.oc.core.debug.js"
7
+ import { ignoredByGitignore, ignoredByOesignore } from "../pware.oc.core/git/pware.oc.core.gitignore.js"
8
+ import { getOes } from "../pware.oc.core/pware.oc.core.oes.js"
9
+ import { ROW_LINE_FALLBACK } from "../pware.oc.core/pware.oc.core.layout.js"
10
+ import { strWidth, takeCols, takeLastCols } from "../pware.oc.core/pware.oc.core.width.js"
11
+ import { eventKind, eventType } from "../pware.oc.core/pware.oc.core.events.js"
12
+ import {
13
+ EVENT_KIND_DB_REFRESH,
14
+ EVENT_KIND_FILE,
15
+ EVENT_KIND_TOOL,
16
+ } from "../pware.oc.core/constants/pware.oc.core.constants.eventKind.js"
17
+ import { basenameOf, finiteNum } from "../pware.oc.core/pware.oc.core.paths.js"
18
+ import { sessionIdFromEvent } from "../pware.oc.core/pware.oc.core.pulse.js"
19
+ import { READ_TOOLS, WRITE_TOOLS } from "../pware.oc.core/constants/pware.oc.core.constants.toolName.js"
20
+ import { PART_TYPE_PATCH } from "../pware.oc.core/constants/pware.oc.core.constants.partType.js"
21
+ import {
22
+ FILE_TOUCH_READ,
23
+ FILE_TOUCH_WRITE,
24
+ type FileTouch,
25
+ } from "./constants/pware.oc.opencode.constants.fileTouch.js"
26
+
27
+ export { basenameOf, FILE_TOUCH_READ, FILE_TOUCH_WRITE, type FileTouch }
28
+
29
+ export const FILE_ROWS = 8
30
+
31
+ export type FileFilter = {
32
+ skipGitignore?: boolean
33
+ projectRoot?: string | null
34
+ }
35
+
36
+ export function fileFilter(projectRoot?: string | null): FileFilter {
37
+ const o = getOes(projectRoot)
38
+ return { skipGitignore: o.skipGitignore, projectRoot: projectRoot ?? null }
39
+ }
40
+
41
+ /** Git porcelain letters, plus `V` (viewed) which git does not use. */
42
+ export type FileLetter = GitLetter | "V"
43
+
44
+ export type FileView = {
45
+ /** Internal key (path). Never shown. */
46
+ id: string
47
+ /** Basename only. */
48
+ name: string
49
+ additions: number
50
+ deletions: number
51
+ at: number
52
+ touch: FileTouch
53
+ letter: FileLetter | null
54
+ }
55
+
56
+ /** Long names: start…end.ext — no directories. Column budget, not code units. */
57
+ export function shortFileName(raw: string, max = ROW_LINE_FALLBACK): string {
58
+ const base = basenameOf(raw)
59
+ if (strWidth(base) <= max) return base
60
+ const dot = base.lastIndexOf(".")
61
+ const ext = dot > 0 && dot >= base.length - 8 ? base.slice(dot) : ""
62
+ const stem = ext ? base.slice(0, -ext.length) : base
63
+ const ellip = "…"
64
+ const room = max - strWidth(ext) - strWidth(ellip)
65
+ if (room < 2) return `${takeCols(base, Math.max(1, max - 1))}${ellip}`
66
+ const tail = Math.min(strWidth(stem), Math.max(3, Math.ceil(room * 0.55)))
67
+ const head = Math.max(1, room - tail)
68
+ if (head + tail >= strWidth(stem)) return takeCols(base, max)
69
+ return `${takeCols(stem, head)}${ellip}${takeLastCols(stem, tail)}${ext}`
70
+ }
71
+
72
+ export function formatDiffStat(add: number, del: number): string {
73
+ const a = add > 0 ? `+${add}` : ""
74
+ const d = del > 0 ? `−${del}` : ""
75
+ return [a, d].filter(Boolean).join(" ")
76
+ }
77
+
78
+ function num(v: unknown): number {
79
+ const n = finiteNum(v)
80
+ return n > 0 ? Math.round(n) : 0
81
+ }
82
+
83
+ function matchInt(data: string, key: string): number {
84
+ const m = data.match(new RegExp(`"${key}"\\s*:\\s*(\\d+)`))
85
+ if (!m?.[1]) return 0
86
+ const n = Number(m[1])
87
+ return Number.isFinite(n) && n > 0 ? n : 0
88
+ }
89
+
90
+ function statsFromBag(o: Record<string, unknown> | null | undefined): { add: number; del: number } {
91
+ if (!o) return { add: 0, del: 0 }
92
+ const fd = o.filediff && typeof o.filediff === "object" ? (o.filediff as Record<string, unknown>) : null
93
+ return {
94
+ add: num(fd?.additions) || num(o.additions),
95
+ del: num(fd?.deletions) || num(o.deletions),
96
+ }
97
+ }
98
+
99
+ function hidden(posixPath: string, filter?: FileFilter): boolean {
100
+ if (ignoredByOesignore(posixPath, filter?.projectRoot)) return true
101
+ return Boolean(filter?.skipGitignore && ignoredByGitignore(posixPath, filter.projectRoot))
102
+ }
103
+
104
+ function asFile(
105
+ pathOrName: string,
106
+ add: number,
107
+ del: number,
108
+ at: number,
109
+ filter?: FileFilter,
110
+ touch: FileTouch = FILE_TOUCH_WRITE,
111
+ ): FileView | null {
112
+ const raw = pathOrName.replace(/\\/g, "/").trim()
113
+ if (!raw || raw === "." || raw === "..") return null
114
+ if (hidden(raw, filter)) return null
115
+ const name = basenameOf(raw)
116
+ return { id: raw, name, additions: add, deletions: del, at, touch, letter: null }
117
+ }
118
+
119
+ function eventBag(evt: unknown): Record<string, unknown> | null {
120
+ if (!evt || typeof evt !== "object") return null
121
+ const o = evt as Record<string, unknown>
122
+ if (o.properties && typeof o.properties === "object") {
123
+ return o.properties as Record<string, unknown>
124
+ }
125
+ return o
126
+ }
127
+
128
+ function pickPath(o: Record<string, unknown>): string | null {
129
+ for (const key of ["file", "filePath", "filepath", "path"]) {
130
+ const v = o[key]
131
+ if (typeof v === "string" && v.trim()) return v.trim()
132
+ }
133
+ return null
134
+ }
135
+
136
+ function fileFromDiffRow(row: unknown, at: number, filter?: FileFilter): FileView | null {
137
+ if (!row || typeof row !== "object") return null
138
+ const o = row as Record<string, unknown>
139
+ const file = pickPath(o)
140
+ if (!file) return null
141
+ return asFile(file, num(o.additions) || num(o.added), num(o.deletions) || num(o.removed), at, filter, FILE_TOUCH_WRITE)
142
+ }
143
+
144
+ /** Paths + +/- only. Drops before/after and any other fields. */
145
+ export function filesFromEvent(evt: unknown, sessionId: string, filter?: FileFilter): FileView[] {
146
+ const type = eventType(evt)
147
+ const bag = eventBag(evt)
148
+ if (!bag) return []
149
+ const sid = sessionIdFromEvent(evt)
150
+ if (sid && sid !== sessionId) return []
151
+ const kind = eventKind(type)
152
+ if (type && kind !== EVENT_KIND_FILE && kind !== EVENT_KIND_TOOL && kind !== EVENT_KIND_DB_REFRESH) return []
153
+ const at = Date.now()
154
+ const out: FileView[] = []
155
+
156
+ if (type.includes("session.diff") || Array.isArray(bag.diff)) {
157
+ const rows = Array.isArray(bag.diff) ? bag.diff : []
158
+ for (const row of rows) {
159
+ const f = fileFromDiffRow(row, at, filter)
160
+ if (f) out.push(f)
161
+ }
162
+ return out
163
+ }
164
+
165
+ if (type.includes("file.edited")) {
166
+ const p = pickPath(bag)
167
+ if (p) {
168
+ const f = asFile(p, 0, 0, at, filter)
169
+ if (f) out.push(f)
170
+ }
171
+ return out
172
+ }
173
+
174
+ if (type.includes("session.updated") || type.includes("session.created")) {
175
+ const info =
176
+ bag.info && typeof bag.info === "object" ? (bag.info as Record<string, unknown>) : bag
177
+ const summary =
178
+ info.summary && typeof info.summary === "object"
179
+ ? (info.summary as Record<string, unknown>)
180
+ : null
181
+ const diffs = summary && Array.isArray(summary.diffs) ? summary.diffs : []
182
+ for (const row of diffs) {
183
+ const f = fileFromDiffRow(row, at, filter)
184
+ if (f) out.push(f)
185
+ }
186
+ return out
187
+ }
188
+
189
+ if (type.includes("tool.called") || type.includes("tool.success") || type.includes("part.updated")) {
190
+ const part =
191
+ bag.part && typeof bag.part === "object" ? (bag.part as Record<string, unknown>) : bag
192
+ const tool = String(part.tool ?? bag.tool ?? "").toLowerCase()
193
+ const touch = touchOfTool(tool)
194
+ if (!touch && !type.includes("file.edited")) return []
195
+ const input =
196
+ part.input && typeof part.input === "object"
197
+ ? (part.input as Record<string, unknown>)
198
+ : bag.input && typeof bag.input === "object"
199
+ ? (bag.input as Record<string, unknown>)
200
+ : null
201
+ const p = input ? pickPath(input) : pickPath(part) ?? pickPath(bag)
202
+ if (!p) return []
203
+ const meta =
204
+ part.metadata && typeof part.metadata === "object"
205
+ ? (part.metadata as Record<string, unknown>)
206
+ : null
207
+ const { add, del } = statsFromBag(meta ?? part)
208
+ const f = asFile(p, add, del, at, filter, touch ?? FILE_TOUCH_WRITE)
209
+ return f ? [f] : []
210
+ }
211
+
212
+ return out
213
+ }
214
+
215
+ const WRITE_TOOL_SET = new Set<string>(WRITE_TOOLS)
216
+ const READ_TOOL_SET = new Set<string>(READ_TOOLS)
217
+
218
+ function touchOfTool(tool: string): FileTouch | null {
219
+ if (READ_TOOL_SET.has(tool)) return FILE_TOUCH_READ
220
+ if (WRITE_TOOL_SET.has(tool)) return FILE_TOUCH_WRITE
221
+ return null
222
+ }
223
+
224
+ const FILEPATH_RE = /"(?:filePath|filepath)"\s*:\s*"((?:\\.|[^"\\])+)"/
225
+ const TOOL_FILE_RE = new RegExp(
226
+ `"tool"\\s*:\\s*"(${[...WRITE_TOOLS, ...READ_TOOLS].join("|")})"`,
227
+ "i",
228
+ )
229
+
230
+ function unescapeJsonString(s: string): string {
231
+ try {
232
+ return JSON.parse(`"${s}"`) as string
233
+ } catch {
234
+ return s.replace(/\\"/g, '"').replace(/\\\\/g, "\\")
235
+ }
236
+ }
237
+
238
+ const PATCH_FILES_RE = /"files"\s*:\s*\[([^\]]*)\]/
239
+
240
+ /** Paths from `part` type patch `{ files: [...] }`. No bodies. */
241
+ export function filesFromPatchData(data: string, at: number, filter?: FileFilter): FileView[] {
242
+ if (!new RegExp(`"type"\\s*:\\s*"${PART_TYPE_PATCH}"`).test(data)) return []
243
+ const block = data.match(PATCH_FILES_RE)?.[1]
244
+ if (!block) return []
245
+ const out: FileView[] = []
246
+ const re = /"((?:\\.|[^"\\])+)"/g
247
+ let m: RegExpExecArray | null
248
+ while ((m = re.exec(block)) !== null) {
249
+ const f = asFile(unescapeJsonString(m[1]), 0, 0, at, filter)
250
+ if (f) out.push(f)
251
+ }
252
+ return out
253
+ }
254
+
255
+ /** Pull filePath from edit/write parts without parsing I/O bodies. */
256
+ export function filePathFromPartData(data: string): string | null {
257
+ if (!TOOL_FILE_RE.test(data)) return null
258
+ const m = data.match(FILEPATH_RE)
259
+ if (!m?.[1]) return null
260
+ return unescapeJsonString(m[1])
261
+ }
262
+
263
+ function touchFromPartData(data: string): FileTouch {
264
+ const t = data.match(TOOL_FILE_RE)?.[1]?.toLowerCase() ?? ""
265
+ return touchOfTool(t) ?? FILE_TOUCH_WRITE
266
+ }
267
+
268
+ /** Path + edit metadata +/- . Ignores patch/diff bodies. */
269
+ export function fileHitFromPartData(data: string, at: number, filter?: FileFilter): FileView | null {
270
+ const raw = filePathFromPartData(data)
271
+ if (!raw) return null
272
+ return asFile(
273
+ raw,
274
+ matchInt(data, "additions"),
275
+ matchInt(data, "deletions"),
276
+ at,
277
+ filter,
278
+ touchFromPartData(data),
279
+ )
280
+ }
281
+
282
+ function firstString(...vals: unknown[]): string | null {
283
+ for (const v of vals) {
284
+ if (typeof v === "string" && v.trim()) return v.trim()
285
+ }
286
+ return null
287
+ }
288
+
289
+ function firstNum(...vals: unknown[]): number {
290
+ for (const v of vals) {
291
+ const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN
292
+ if (Number.isFinite(n) && n > 0) return Math.round(n)
293
+ }
294
+ return 0
295
+ }
296
+
297
+ /** Patch `files` from json_extract — array or JSON text. No bodies. */
298
+ export function filesFromPatchJson(raw: unknown, at: number, filter?: FileFilter): FileView[] {
299
+ let list: unknown = raw
300
+ if (typeof raw === "string") {
301
+ const t = raw.trim()
302
+ if (!t) return []
303
+ try {
304
+ list = JSON.parse(t)
305
+ } catch {
306
+ return []
307
+ }
308
+ }
309
+ if (!Array.isArray(list)) return []
310
+ const out: FileView[] = []
311
+ for (const item of list) {
312
+ const p =
313
+ typeof item === "string"
314
+ ? item
315
+ : firstString(
316
+ item && typeof item === "object" ? (item as Record<string, unknown>).filePath : null,
317
+ item && typeof item === "object" ? (item as Record<string, unknown>).path : null,
318
+ )
319
+ if (!p) continue
320
+ const f = asFile(p, 0, 0, at, filter)
321
+ if (f) out.push(f)
322
+ }
323
+ return out
324
+ }
325
+
326
+ /** Path + +/- from extracted columns. Never needs the part blob. */
327
+ export function fileHitFromExtracted(opts: {
328
+ tool?: string | null
329
+ filePath?: string | null
330
+ filePathAlt?: string | null
331
+ additions?: unknown
332
+ deletions?: unknown
333
+ at: number
334
+ filter?: FileFilter
335
+ }): FileView | null {
336
+ const raw = firstString(opts.filePath, opts.filePathAlt)
337
+ if (!raw) return null
338
+ const tool = (opts.tool || "").toLowerCase()
339
+ const touch = touchOfTool(tool)
340
+ if (tool && !touch) return null
341
+ return asFile(raw, firstNum(opts.additions), firstNum(opts.deletions), opts.at, opts.filter, touch ?? FILE_TOUCH_WRITE)
342
+ }
343
+
344
+ /** Git porcelain wins. `V` (viewed) only when git has no letter — git does not use V. */
345
+ export function decorateFiles(
346
+ files: FileView[],
347
+ projectRoot?: string | null,
348
+ opts?: { git?: boolean },
349
+ ): FileView[] {
350
+ if (!files.length) return files
351
+ return profile("files.decorate", () => {
352
+ if (opts?.git === false) {
353
+ return files.map((f) => ({
354
+ ...f,
355
+ letter: f.letter ?? (f.touch === FILE_TOUCH_READ ? "V" : null),
356
+ }))
357
+ }
358
+ const { root, marks } = readGitMarksFor(
359
+ files.map((f) => f.id),
360
+ projectRoot ?? null,
361
+ )
362
+ return files.map((f) => {
363
+ const git = root
364
+ ? marks.get(relToGitRoot(f.id, root)) ?? marks.get(f.id.replace(/\\/g, "/").toLowerCase()) ?? null
365
+ : null
366
+ return { ...f, letter: git ?? (f.touch === FILE_TOUCH_READ ? "V" : null) }
367
+ })
368
+ })
369
+ }
370
+
371
+ export function sumDiff(files: FileView[]): { additions: number; deletions: number } {
372
+ let additions = 0
373
+ let deletions = 0
374
+ for (const f of files) {
375
+ additions += f.additions
376
+ deletions += f.deletions
377
+ }
378
+ return { additions, deletions }
379
+ }
380
+
381
+ export function mergeFiles(fromDb: FileView[] | null | undefined, live: Record<string, FileView>): FileView[] {
382
+ const byId = new Map<string, FileView>()
383
+ for (const f of fromDb ?? []) {
384
+ byId.set(f.id, { ...f, touch: f.touch ?? FILE_TOUCH_WRITE, letter: f.letter ?? null })
385
+ }
386
+ for (const f of Object.values(live)) {
387
+ const prev = byId.get(f.id)
388
+ byId.set(f.id, {
389
+ id: f.id,
390
+ name: f.name,
391
+ additions: Math.max(f.additions, prev?.additions ?? 0),
392
+ deletions: Math.max(f.deletions, prev?.deletions ?? 0),
393
+ at: Math.max(f.at, prev?.at ?? 0),
394
+ touch: prev?.touch === FILE_TOUCH_WRITE || f.touch === FILE_TOUCH_WRITE ? FILE_TOUCH_WRITE : FILE_TOUCH_READ,
395
+ letter: f.letter ?? prev?.letter ?? null,
396
+ })
397
+ }
398
+ return [...byId.values()].sort((a, b) => b.at - a.at)
399
+ }