oc-metricboard 0.1.4
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/README.md +137 -0
- package/package.json +92 -0
- package/src/assistant-progress.ts +99 -0
- package/src/badge-contrast.ts +62 -0
- package/src/collector-state.ts +36 -0
- package/src/collector.ts +615 -0
- package/src/components/SidebarMetrics.tsx +331 -0
- package/src/components/StatRow.tsx +83 -0
- package/src/config.ts +59 -0
- package/src/event-bus.ts +61 -0
- package/src/event-handlers.ts +345 -0
- package/src/event-shapes.ts +198 -0
- package/src/live-speed.ts +79 -0
- package/src/logger.ts +36 -0
- package/src/metrics.ts +243 -0
- package/src/request-state.ts +100 -0
- package/src/request-updates.ts +40 -0
- package/src/scripts/init-tui-preferences.ts +107 -0
- package/src/session-hydration.ts +278 -0
- package/src/session-timing.ts +78 -0
- package/src/session-tree.ts +43 -0
- package/src/tui-preferences.ts +203 -0
- package/src/tui-prefs-io.ts +159 -0
- package/src/tui.tsx +60 -0
- package/src/turn-state.ts +85 -0
- package/src/types.ts +138 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { readFileSync, watch } from "node:fs"
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises"
|
|
3
|
+
import { homedir } from "node:os"
|
|
4
|
+
import { basename, dirname, join } from "node:path"
|
|
5
|
+
import { parse, stringify } from "comment-json"
|
|
6
|
+
|
|
7
|
+
// Generic preferences file I/O for OpenCode TUI plugins.
|
|
8
|
+
// Plugin-specific types, defaults, and validation live in tui-preferences.ts.
|
|
9
|
+
|
|
10
|
+
export const TUI_PREFS_FILE_ENV = "OPENCODE_TUI_PREFERENCES_FILE"
|
|
11
|
+
const FILE_NAME = "tui-preferences.jsonc"
|
|
12
|
+
|
|
13
|
+
export function getTuiPreferencesFile(): string {
|
|
14
|
+
const override = process.env[TUI_PREFS_FILE_ENV]
|
|
15
|
+
if (override) return override
|
|
16
|
+
const configDir =
|
|
17
|
+
process.env.OPENCODE_CONFIG_DIR ||
|
|
18
|
+
join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "opencode")
|
|
19
|
+
return join(configDir, FILE_NAME)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
23
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Tolerant read: a missing file, parse error, or non-object root all resolve
|
|
27
|
+
// to {} so the sidebar never crashes on hand-edited content. Never throws.
|
|
28
|
+
export async function readTuiPreferencesFile(): Promise<Record<string, unknown>> {
|
|
29
|
+
try {
|
|
30
|
+
const raw = await readFile(getTuiPreferencesFile(), "utf8")
|
|
31
|
+
if (raw.trim() === "") return {}
|
|
32
|
+
const root: unknown = parse(raw)
|
|
33
|
+
return isRecord(root) ? (root as Record<string, unknown>) : {}
|
|
34
|
+
} catch {
|
|
35
|
+
return {}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Synchronous tolerant read — used once at slot mount to seed the initial
|
|
40
|
+
// collapse state and effective order WITHOUT a frame of async flicker.
|
|
41
|
+
export function readTuiPreferencesFileSync(): Record<string, unknown> {
|
|
42
|
+
try {
|
|
43
|
+
const raw = readFileSync(getTuiPreferencesFile(), "utf8")
|
|
44
|
+
if (raw.trim() === "") return {}
|
|
45
|
+
const root: unknown = parse(raw)
|
|
46
|
+
return isRecord(root) ? (root as Record<string, unknown>) : {}
|
|
47
|
+
} catch {
|
|
48
|
+
return {}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const TEMPLATE = `// Shared preferences for OpenCode TUI plugins.
|
|
53
|
+
// One top-level key per plugin (short name). See each plugin's README for its
|
|
54
|
+
// supported settings. This file is safe to hand-edit; plugins update individual
|
|
55
|
+
// keys and preserve the rest (values and comments).
|
|
56
|
+
{}
|
|
57
|
+
`
|
|
58
|
+
|
|
59
|
+
type JsonValue = string | number | boolean | null
|
|
60
|
+
|
|
61
|
+
function setDeep(root: Record<string, unknown>, path: string[], value: JsonValue): boolean {
|
|
62
|
+
let node: Record<string, unknown> = root
|
|
63
|
+
for (let i = 0; i < path.length - 1; i += 1) {
|
|
64
|
+
const key = path[i]
|
|
65
|
+
const child = node[key]
|
|
66
|
+
if (child === undefined || child === null) {
|
|
67
|
+
node[key] = {}
|
|
68
|
+
} else if (!isRecord(child)) {
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
node = node[key] as Record<string, unknown>
|
|
72
|
+
}
|
|
73
|
+
node[path[path.length - 1]] = value
|
|
74
|
+
return true
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function writePreference(pluginKey: string, path: string[], value: JsonValue): Promise<void> {
|
|
78
|
+
const file = getTuiPreferencesFile()
|
|
79
|
+
await mkdir(dirname(file), { recursive: true })
|
|
80
|
+
let text: string
|
|
81
|
+
try {
|
|
82
|
+
text = await readFile(file, "utf8")
|
|
83
|
+
} catch {
|
|
84
|
+
text = ""
|
|
85
|
+
}
|
|
86
|
+
if (text.trim() === "") text = TEMPLATE
|
|
87
|
+
|
|
88
|
+
let root: unknown
|
|
89
|
+
try {
|
|
90
|
+
root = parse(text)
|
|
91
|
+
} catch {
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
if (!isRecord(root)) root = {}
|
|
95
|
+
if (!setDeep(root as Record<string, unknown>, [pluginKey, ...path], value)) {
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const next = `${stringify(root, null, 2)}\n`
|
|
100
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
101
|
+
await writeFile(tmp, next, "utf8")
|
|
102
|
+
await rename(tmp, file)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let writeChain: Promise<void> = Promise.resolve()
|
|
106
|
+
|
|
107
|
+
// Writes are serialized on a promise chain: each update re-reads the file,
|
|
108
|
+
// applies a comment-preserving edit to one property, and replaces the file
|
|
109
|
+
// atomically (temp + rename in the same directory).
|
|
110
|
+
export function queueTuiPreferenceUpdate(
|
|
111
|
+
pluginKey: string,
|
|
112
|
+
path: string[],
|
|
113
|
+
value: JsonValue,
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
writeChain = writeChain.then(() => writePreference(pluginKey, path, value)).catch(() => {})
|
|
116
|
+
return writeChain
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const WATCH_DEBOUNCE_MS = 150
|
|
120
|
+
|
|
121
|
+
// Watches the DIRECTORY, not the file: editors and our own atomic writes
|
|
122
|
+
// replace the file via rename, which kills file-level watchers.
|
|
123
|
+
export function watchTuiPreferences(onChange: () => void): () => void {
|
|
124
|
+
const file = getTuiPreferencesFile()
|
|
125
|
+
const name = basename(file)
|
|
126
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
127
|
+
let lastSeen: string | null = null
|
|
128
|
+
void readFile(file, "utf8")
|
|
129
|
+
.then((text) => {
|
|
130
|
+
if (lastSeen === null) lastSeen = text
|
|
131
|
+
})
|
|
132
|
+
.catch(() => {})
|
|
133
|
+
try {
|
|
134
|
+
const watcher = watch(dirname(file), (_event, filename) => {
|
|
135
|
+
const isOurs =
|
|
136
|
+
filename === name ||
|
|
137
|
+
(filename?.startsWith(`${name}.`) && filename.endsWith(".tmp"))
|
|
138
|
+
if (filename != null && !isOurs) return
|
|
139
|
+
if (timer) clearTimeout(timer)
|
|
140
|
+
timer = setTimeout(() => {
|
|
141
|
+
timer = null
|
|
142
|
+
void readFile(file, "utf8")
|
|
143
|
+
.catch(() => null)
|
|
144
|
+
.then((text) => {
|
|
145
|
+
if (text === null) return
|
|
146
|
+
if (text === lastSeen) return
|
|
147
|
+
lastSeen = text
|
|
148
|
+
onChange()
|
|
149
|
+
})
|
|
150
|
+
}, WATCH_DEBOUNCE_MS)
|
|
151
|
+
})
|
|
152
|
+
return () => {
|
|
153
|
+
if (timer) clearTimeout(timer)
|
|
154
|
+
watcher.close()
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
return () => {}
|
|
158
|
+
}
|
|
159
|
+
}
|
package/src/tui.tsx
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
/** @jsxRuntime automatic */
|
|
3
|
+
import type { TuiPlugin, TuiPluginApi, TuiPluginMeta } from "@opencode-ai/plugin/tui"
|
|
4
|
+
import type { PluginOptions } from "@opencode-ai/plugin"
|
|
5
|
+
import { createCollector } from "./collector"
|
|
6
|
+
import { getConfig } from "./config"
|
|
7
|
+
import { log } from "./logger"
|
|
8
|
+
import { SidebarMetrics } from "./components/SidebarMetrics"
|
|
9
|
+
import {
|
|
10
|
+
computeEffectiveOrder,
|
|
11
|
+
createMetricsSidebarController,
|
|
12
|
+
DEFAULT_SLOT_ORDER,
|
|
13
|
+
PLUGIN_KEY,
|
|
14
|
+
resolveMetricsPrefs,
|
|
15
|
+
} from "./tui-preferences"
|
|
16
|
+
import { readTuiPreferencesFileSync } from "./tui-prefs-io"
|
|
17
|
+
|
|
18
|
+
const plugin: TuiPlugin = async (api: TuiPluginApi, _options: PluginOptions | undefined, _meta: TuiPluginMeta) => {
|
|
19
|
+
const config = getConfig()
|
|
20
|
+
const collector = createCollector(api, config, log)
|
|
21
|
+
|
|
22
|
+
// Sync-read preferences at slot mount so the sidebar renders at its final
|
|
23
|
+
// order and collapse state on the first paint (no async flicker).
|
|
24
|
+
const seedRoot = readTuiPreferencesFileSync()
|
|
25
|
+
const effectiveOrder = computeEffectiveOrder(seedRoot, PLUGIN_KEY, DEFAULT_SLOT_ORDER)
|
|
26
|
+
const prefs = resolveMetricsPrefs(seedRoot)
|
|
27
|
+
const controller = createMetricsSidebarController(prefs, () => api.renderer.requestRender())
|
|
28
|
+
|
|
29
|
+
api.slots.register({
|
|
30
|
+
order: effectiveOrder,
|
|
31
|
+
slots: {
|
|
32
|
+
sidebar_content(ctx, props) {
|
|
33
|
+
return (
|
|
34
|
+
<SidebarMetrics
|
|
35
|
+
sessionID={props.session_id}
|
|
36
|
+
collector={collector}
|
|
37
|
+
refreshIntervalMs={config.refreshIntervalMs}
|
|
38
|
+
barConfig={config}
|
|
39
|
+
theme={ctx.theme.current}
|
|
40
|
+
controller={controller}
|
|
41
|
+
requestRender={() => api.renderer.requestRender()}
|
|
42
|
+
/>
|
|
43
|
+
)
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
api.lifecycle.onDispose(() => {
|
|
49
|
+
collector.dispose()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
log("opencode-metrics sidebar initialized")
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const pluginModule: { id: string; tui: TuiPlugin } = {
|
|
56
|
+
id: "oc-metricboard",
|
|
57
|
+
tui: plugin,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export default pluginModule
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { getDisplayInputTokens, getDisplayOutputTokens } from "./metrics"
|
|
2
|
+
import type { RequestMetrics, TurnMetrics } from "./types"
|
|
3
|
+
|
|
4
|
+
export function createTurnMetrics(sessionID: string, now: number): TurnMetrics {
|
|
5
|
+
return {
|
|
6
|
+
sessionID,
|
|
7
|
+
turnStartTime: now,
|
|
8
|
+
completeTime: null,
|
|
9
|
+
finalizedOutputTokens: 0,
|
|
10
|
+
finalizedSteps: new Map(),
|
|
11
|
+
stickyContextTokens: 0,
|
|
12
|
+
stickyContextUpdatedAt: Number.NEGATIVE_INFINITY,
|
|
13
|
+
hasStickyContextTokens: false,
|
|
14
|
+
stickyCacheReadTokens: 0,
|
|
15
|
+
hasStickyCacheReadTokens: false,
|
|
16
|
+
isComplete: false,
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function ensureTurn(
|
|
21
|
+
turns: Map<string, TurnMetrics>,
|
|
22
|
+
sessionID: string,
|
|
23
|
+
now: number,
|
|
24
|
+
): TurnMetrics {
|
|
25
|
+
const existing = turns.get(sessionID)
|
|
26
|
+
if (existing) return existing
|
|
27
|
+
const turn = createTurnMetrics(sessionID, now)
|
|
28
|
+
turns.set(sessionID, turn)
|
|
29
|
+
return turn
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function startTurn(
|
|
33
|
+
turns: Map<string, TurnMetrics>,
|
|
34
|
+
sessionID: string,
|
|
35
|
+
now: number,
|
|
36
|
+
): TurnMetrics {
|
|
37
|
+
const turn = createTurnMetrics(sessionID, now)
|
|
38
|
+
turns.set(sessionID, turn)
|
|
39
|
+
return turn
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function retireRequestIntoTurn(
|
|
43
|
+
turn: TurnMetrics,
|
|
44
|
+
request: RequestMetrics,
|
|
45
|
+
updateSticky = true,
|
|
46
|
+
): boolean {
|
|
47
|
+
if (!request.messageID) return false
|
|
48
|
+
const outputTokens = getDisplayOutputTokens(request)
|
|
49
|
+
const exact = request.hasExactTokens
|
|
50
|
+
const previous = turn.finalizedSteps.get(request.messageID)
|
|
51
|
+
|
|
52
|
+
if (!previous) {
|
|
53
|
+
turn.finalizedSteps.set(request.messageID, { outputTokens, exact })
|
|
54
|
+
turn.finalizedOutputTokens += outputTokens
|
|
55
|
+
} else if (!previous.exact && exact) {
|
|
56
|
+
turn.finalizedOutputTokens += outputTokens - previous.outputTokens
|
|
57
|
+
turn.finalizedSteps.set(request.messageID, { outputTokens, exact: true })
|
|
58
|
+
} else {
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (updateSticky && exact && request.requestStartTime >= turn.stickyContextUpdatedAt) {
|
|
63
|
+
turn.stickyContextTokens = getDisplayInputTokens(request)
|
|
64
|
+
turn.stickyContextUpdatedAt = request.requestStartTime
|
|
65
|
+
turn.hasStickyContextTokens = true
|
|
66
|
+
turn.stickyCacheReadTokens = Math.max(0, request.exactCacheReadTokens)
|
|
67
|
+
turn.hasStickyCacheReadTokens = request.hasExactCacheReadTokens
|
|
68
|
+
}
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function liveRequestOutput(turn: TurnMetrics, request: RequestMetrics | undefined): number {
|
|
73
|
+
if (!request?.messageID || turn.finalizedSteps.has(request.messageID)) return 0
|
|
74
|
+
return getDisplayOutputTokens(request)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function turnInputTokens(turn: TurnMetrics, request: RequestMetrics | undefined): number {
|
|
78
|
+
if (turn.hasStickyContextTokens) return turn.stickyContextTokens
|
|
79
|
+
return request ? getDisplayInputTokens(request) : 0
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function completeTurn(turn: TurnMetrics, now: number): void {
|
|
83
|
+
turn.isComplete = true
|
|
84
|
+
turn.completeTime = now
|
|
85
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// src/types.ts
|
|
2
|
+
|
|
3
|
+
export interface RequestMetrics {
|
|
4
|
+
sessionID: string
|
|
5
|
+
messageID: string
|
|
6
|
+
modelID: string
|
|
7
|
+
providerID: string
|
|
8
|
+
|
|
9
|
+
// Timing
|
|
10
|
+
requestStartTime: number // performance.now()
|
|
11
|
+
firstTokenTime: number | null // Timestamp when first text delta arrives
|
|
12
|
+
lastDeltaTime: number | null // Last delta arrival time
|
|
13
|
+
completeTime: number | null // Idle timestamp
|
|
14
|
+
|
|
15
|
+
// Token counts
|
|
16
|
+
estimatedInputTokens: number // Estimated from user message content length / ratio
|
|
17
|
+
estimatedOutputTokens: number // Accumulated from delta.length / ratio
|
|
18
|
+
exactInputTokens: number // From AssistantMessage.tokens
|
|
19
|
+
exactOutputTokens: number
|
|
20
|
+
exactCacheReadTokens: number
|
|
21
|
+
exactCacheWriteTokens: number
|
|
22
|
+
exactReasoningTokens: number
|
|
23
|
+
hasExactTokens: boolean // Whether exact values have been received
|
|
24
|
+
hasExactCacheReadTokens: boolean
|
|
25
|
+
hasExactCacheWriteTokens: boolean
|
|
26
|
+
|
|
27
|
+
// State
|
|
28
|
+
isStreaming: boolean // Whether still streaming
|
|
29
|
+
isComplete: boolean // Whether request is complete (idle)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface FinalizedStepUsage {
|
|
33
|
+
outputTokens: number
|
|
34
|
+
exact: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface TurnMetrics {
|
|
38
|
+
sessionID: string
|
|
39
|
+
turnStartTime: number
|
|
40
|
+
completeTime: number | null
|
|
41
|
+
finalizedOutputTokens: number
|
|
42
|
+
finalizedSteps: Map<string, FinalizedStepUsage>
|
|
43
|
+
stickyContextTokens: number
|
|
44
|
+
stickyContextUpdatedAt: number
|
|
45
|
+
hasStickyContextTokens: boolean
|
|
46
|
+
stickyCacheReadTokens: number
|
|
47
|
+
hasStickyCacheReadTokens: boolean
|
|
48
|
+
isComplete: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface LiveSpeedSample {
|
|
52
|
+
at: number
|
|
53
|
+
cumulativeTokens: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface LiveSpeedState {
|
|
57
|
+
messageID: string
|
|
58
|
+
cumulativeTokens: number
|
|
59
|
+
samples: LiveSpeedSample[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type MetricsScope = "current" | "tree"
|
|
63
|
+
|
|
64
|
+
export type CacheReadCompleteness = "exact" | "partial" | "unknown"
|
|
65
|
+
|
|
66
|
+
export interface ModelMetrics {
|
|
67
|
+
modelID: string
|
|
68
|
+
providerID: string
|
|
69
|
+
sessionID: string
|
|
70
|
+
// Number of sub-agent sessions sharing this (provider, model) — used for the
|
|
71
|
+
// "×N" count in the model-grouped breakdown.
|
|
72
|
+
sessionCount: number
|
|
73
|
+
inputTokens: number
|
|
74
|
+
outputTokens: number
|
|
75
|
+
cacheReadTokens: number
|
|
76
|
+
cacheReadCompleteness: CacheReadCompleteness
|
|
77
|
+
requestStartTime: number
|
|
78
|
+
firstTokenTime: number | null
|
|
79
|
+
completeTime: number | null
|
|
80
|
+
ttft: number | null
|
|
81
|
+
liveTps: number | null
|
|
82
|
+
isStreaming: boolean
|
|
83
|
+
isComplete: boolean
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface MetricsAggregate {
|
|
87
|
+
sessionIDs: string[]
|
|
88
|
+
childSessionCount: number
|
|
89
|
+
inputTokens: number
|
|
90
|
+
outputTokens: number
|
|
91
|
+
cacheReadTokens: number
|
|
92
|
+
cacheReadCompleteness: CacheReadCompleteness
|
|
93
|
+
requestStartTime: number
|
|
94
|
+
firstTokenTime: number | null
|
|
95
|
+
completeTime: number | null
|
|
96
|
+
ttft: number | null
|
|
97
|
+
liveTps: number | null
|
|
98
|
+
isStreaming: boolean
|
|
99
|
+
isComplete: boolean
|
|
100
|
+
// NEW: per-model breakdown for tree scope
|
|
101
|
+
modelBreakdown: ModelMetrics[]
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface BarConfig {
|
|
105
|
+
refreshIntervalMs: number
|
|
106
|
+
holdDurationMs: number
|
|
107
|
+
estimationRatio: number
|
|
108
|
+
enableLogging: boolean
|
|
109
|
+
visible: {
|
|
110
|
+
speed: boolean
|
|
111
|
+
ttft: boolean
|
|
112
|
+
input: boolean
|
|
113
|
+
output: boolean
|
|
114
|
+
cache: boolean
|
|
115
|
+
elapsed: boolean
|
|
116
|
+
session: boolean
|
|
117
|
+
model: boolean
|
|
118
|
+
modelBreakdown: boolean
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const DEFAULT_CONFIG: BarConfig = {
|
|
123
|
+
refreshIntervalMs: 200,
|
|
124
|
+
holdDurationMs: 0,
|
|
125
|
+
estimationRatio: 4.0,
|
|
126
|
+
enableLogging: false,
|
|
127
|
+
visible: {
|
|
128
|
+
speed: true,
|
|
129
|
+
ttft: true,
|
|
130
|
+
input: true,
|
|
131
|
+
output: true,
|
|
132
|
+
cache: false,
|
|
133
|
+
elapsed: true,
|
|
134
|
+
session: true,
|
|
135
|
+
model: true,
|
|
136
|
+
modelBreakdown: true,
|
|
137
|
+
},
|
|
138
|
+
}
|