@xingular/harness 0.1.5 → 0.1.7
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/CHANGELOG.md +11 -0
- package/NOTICE.md +38 -0
- package/README.md +118 -26
- package/dist/cli.js +6106 -912
- package/dist/terminal-pi.js +462 -11
- package/docs/README.md +12 -0
- package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
- package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/verification.json +130 -0
- package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +95 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +27 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +18 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +95 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +60 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +24 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +57 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +58 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +320 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +30 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +133 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +20 -0
- package/docs/evidence/terminal-2026-10-17-followup/verification.json +207 -0
- package/docs/terminal-tui.md +254 -0
- package/package.json +8 -2
- package/src/accounts/accounts.test.ts +110 -0
- package/src/accounts/accounts.ts +146 -10
- package/src/cli.ts +7 -3
- package/src/harness.ts +16 -3
- package/src/hub/catalog.json +123 -0
- package/src/hub/cli.ts +59 -0
- package/src/hub/hub.test.ts +205 -0
- package/src/hub/hub.ts +256 -0
- package/src/hub/schema.ts +81 -0
- package/src/index.ts +6 -1
- package/src/memory/api.ts +16 -2
- package/src/memory/blocks.ts +3 -1
- package/src/memory/caller.ts +13 -1
- package/src/memory/curation.test.ts +163 -0
- package/src/memory/curation.ts +64 -0
- package/src/memory/maintenance.ts +199 -11
- package/src/memory/operations.ts +12 -0
- package/src/memory/proposals.ts +16 -8
- package/src/memory/recall.test.ts +11 -0
- package/src/memory/recall.ts +9 -2
- package/src/server/methods.ts +37 -2
- package/src/sessions/contracts.ts +29 -3
- package/src/sessions/conversation.test.ts +27 -0
- package/src/sessions/policy.ts +4 -0
- package/src/sessions/questionBatches.test.ts +110 -0
- package/src/sessions/registry.ts +109 -22
- package/src/sessions/runner.ts +36 -1
- package/src/sessions/sessions.test.ts +95 -1
- package/src/sessions/sessions.ts +53 -9
- package/src/skills/registry.ts +16 -10
- package/src/terminal/accountsMenu.test.ts +225 -0
- package/src/terminal/accountsMenu.ts +382 -0
- package/src/terminal/control.test.ts +23 -0
- package/src/terminal/control.ts +23 -0
- package/src/terminal/controller.ts +68 -4
- package/src/terminal/hubMenu.ts +24 -0
- package/src/terminal/launch.test.ts +5 -14
- package/src/terminal/launch.ts +2 -42
- package/src/terminal/piExtension.lifecycle.test.ts +55 -0
- package/src/terminal/piExtension.ts +50 -1
- package/src/terminal/socket.ts +13 -2
- package/src/terminal/standalone.test.ts +37 -0
- package/src/terminal/standalone.ts +16 -7
- package/src/terminal/terminal.test.ts +70 -1
- package/src/terminal/terminals.ts +122 -12
- package/src/terminal/tui/application.test.ts +114 -0
- package/src/terminal/tui/application.ts +491 -0
- package/src/terminal/tui/commands.test.ts +218 -0
- package/src/terminal/tui/commands.ts +361 -0
- package/src/terminal/tui/dialogs.ts +301 -0
- package/src/terminal/tui/editor.test.ts +84 -0
- package/src/terminal/tui/editor.ts +79 -0
- package/src/terminal/tui/keybindings.test.ts +22 -0
- package/src/terminal/tui/keybindings.ts +50 -0
- package/src/terminal/tui/piAdapter.ts +62 -0
- package/src/terminal/tui/presentation.test.ts +79 -0
- package/src/terminal/tui/questions.test.ts +41 -0
- package/src/terminal/tui/questions.ts +47 -0
- package/src/terminal/tui/runtime.socket.test.ts +104 -0
- package/src/terminal/tui/runtime.test.ts +215 -0
- package/src/terminal/tui/runtime.ts +388 -0
- package/src/terminal/tui/sessionUi.ts +24 -0
- package/src/terminal/tui/settings.test.ts +150 -0
- package/src/terminal/tui/settings.ts +188 -0
- package/src/terminal/tui/status.test.ts +70 -0
- package/src/terminal/tui/status.ts +109 -0
- package/src/terminal/tui/theme.ts +174 -0
- package/src/terminal/tui/tools.test.ts +145 -0
- package/src/terminal/tui/tools.ts +142 -0
- package/src/terminal/tui/transcript.test.ts +25 -0
- package/src/terminal/tui/transcript.ts +124 -0
- package/src/terminal/tui/ui.test.ts +313 -0
- package/src/terminal/tui/ui.ts +166 -0
- package/src/terminal/tui/welcome.ts +102 -0
- package/src/tools/askUser.test.ts +69 -0
- package/src/tools/askUser.ts +31 -17
- package/src/tools/maintenance.ts +24 -0
- package/src/tools/memoryTools.ts +1 -1
- package/src/tools/registryWrite.test.ts +101 -0
- package/src/tools/registryWrite.ts +182 -0
- package/src/tools/spawnSubagent.ts +92 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings chrome adapted from Oh My Pi 18.11.3 settings-selector.ts and
|
|
3
|
+
* settings-list.ts (MIT; see NOTICE-oh-my-pi). Values and actions belong to the host.
|
|
4
|
+
*/
|
|
5
|
+
import type { Theme } from '@earendil-works/pi-coding-agent'
|
|
6
|
+
import { Input, fuzzyFilter, matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type Focusable, type TUI } from '@earendil-works/pi-tui'
|
|
7
|
+
|
|
8
|
+
export interface TerminalSettingItem {
|
|
9
|
+
id: string
|
|
10
|
+
tab: string
|
|
11
|
+
group: string
|
|
12
|
+
label: string
|
|
13
|
+
value: string
|
|
14
|
+
description?: string
|
|
15
|
+
}
|
|
16
|
+
const categories = ['appearance', 'model', 'interaction', 'context'] as const
|
|
17
|
+
const categoryIcon: Record<string, string> = { appearance: '🎨', model: '🤖', interaction: '⌨', context: '📋' }
|
|
18
|
+
const categoryName = (value: string) => value.charAt(0).toUpperCase() + value.slice(1)
|
|
19
|
+
const plain = (value: string) => stripTerminalSequences(value).replace(/[\x00-\x1f\x7f]/g, ' ')
|
|
20
|
+
const tabOf = (item: TerminalSettingItem) => item.tab.toLowerCase()
|
|
21
|
+
|
|
22
|
+
/** A full terminal settings view. It describes existing host settings without owning them. */
|
|
23
|
+
export class SettingsPanel implements Component, Focusable {
|
|
24
|
+
private search = new Input({ prompt: '' })
|
|
25
|
+
private query = ''
|
|
26
|
+
private currentTab = ''
|
|
27
|
+
private selected: string | undefined
|
|
28
|
+
private groupFocus = false
|
|
29
|
+
private focusedValue = false
|
|
30
|
+
private remembered = new Map<string, string>()
|
|
31
|
+
private readonly items: TerminalSettingItem[]
|
|
32
|
+
private readonly tabs: string[]
|
|
33
|
+
constructor(private readonly tui: TUI, private readonly theme: () => Theme, items: TerminalSettingItem[], private readonly done: (id: string | undefined) => void, initialId?: string) {
|
|
34
|
+
this.items = items.filter(item => categories.includes(tabOf(item) as typeof categories[number]))
|
|
35
|
+
this.tabs = categories.filter(tab => this.items.some(item => tabOf(item) === tab))
|
|
36
|
+
const initial = this.items.find(item => item.id === initialId)
|
|
37
|
+
this.currentTab = initial ? tabOf(initial) : this.tabs[0] ?? ''
|
|
38
|
+
this.selected = initial?.id ?? this.visibleItems()[0]?.id
|
|
39
|
+
}
|
|
40
|
+
get selectedId(): string | undefined { return this.selected }
|
|
41
|
+
get focused(): boolean { return this.focusedValue }
|
|
42
|
+
set focused(value: boolean) { this.focusedValue = value; this.search.focused = value && this.query.length > 0 }
|
|
43
|
+
invalidate(): void { this.search.invalidate() }
|
|
44
|
+
private visibleItems(): TerminalSettingItem[] {
|
|
45
|
+
return this.query
|
|
46
|
+
? fuzzyFilter(this.items, this.query, item => `${item.label} ${item.id} ${item.value} ${item.description ?? ''} ${item.group} ${item.tab}`)
|
|
47
|
+
: this.items.filter(item => tabOf(item) === this.currentTab)
|
|
48
|
+
}
|
|
49
|
+
private select(item: TerminalSettingItem | undefined): void {
|
|
50
|
+
this.selected = item?.id
|
|
51
|
+
if (item) { this.currentTab = tabOf(item); this.remembered.set(this.currentTab, item.id) }
|
|
52
|
+
}
|
|
53
|
+
private switchTab(direction: number): void {
|
|
54
|
+
const tabs = this.query ? this.tabs.filter(tab => this.visibleItems().some(item => tabOf(item) === tab)) : this.tabs
|
|
55
|
+
if (!tabs.length) return
|
|
56
|
+
const at = Math.max(0, tabs.indexOf(this.currentTab))
|
|
57
|
+
this.currentTab = tabs[(at + direction + tabs.length) % tabs.length]
|
|
58
|
+
const items = this.visibleItems().filter(item => tabOf(item) === this.currentTab)
|
|
59
|
+
this.select(items.find(item => item.id === this.remembered.get(this.currentTab)) ?? items[0])
|
|
60
|
+
this.groupFocus = false
|
|
61
|
+
}
|
|
62
|
+
private jumpGroup(direction: number): void {
|
|
63
|
+
const items = this.visibleItems()
|
|
64
|
+
const groups = [...new Set(items.map(item => item.group))]
|
|
65
|
+
const current = items.find(item => item.id === this.selected)?.group
|
|
66
|
+
const index = Math.max(0, groups.indexOf(current ?? ''))
|
|
67
|
+
const group = groups[(index + direction + groups.length) % groups.length]
|
|
68
|
+
this.select(items.find(item => item.group === group))
|
|
69
|
+
}
|
|
70
|
+
handleInput(data: string): void {
|
|
71
|
+
if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c')) { this.done(undefined); return }
|
|
72
|
+
if (matchesKey(data, 'left') || matchesKey(data, 'right')) { this.switchTab(matchesKey(data, 'left') ? -1 : 1); return }
|
|
73
|
+
if (matchesKey(data, 'tab') || matchesKey(data, 'shift+tab')) {
|
|
74
|
+
if (this.query) this.switchTab(matchesKey(data, 'shift+tab') ? -1 : 1)
|
|
75
|
+
else if (new Set(this.visibleItems().map(item => item.group)).size > 1) this.groupFocus = !this.groupFocus
|
|
76
|
+
else this.switchTab(matchesKey(data, 'shift+tab') ? -1 : 1)
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
if (matchesKey(data, 'up') || matchesKey(data, 'down') || matchesKey(data, 'pageUp') || matchesKey(data, 'pageDown')) {
|
|
80
|
+
const direction = matchesKey(data, 'up') || matchesKey(data, 'pageUp') ? -1 : 1
|
|
81
|
+
if (this.groupFocus || matchesKey(data, 'pageUp') || matchesKey(data, 'pageDown')) this.jumpGroup(direction)
|
|
82
|
+
else {
|
|
83
|
+
const items = this.visibleItems()
|
|
84
|
+
const at = Math.max(0, items.findIndex(item => item.id === this.selected))
|
|
85
|
+
this.select(items[Math.max(0, Math.min(items.length - 1, at + direction))])
|
|
86
|
+
}
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
if (matchesKey(data, 'enter') || data === ' ') {
|
|
90
|
+
if (this.groupFocus) this.groupFocus = false
|
|
91
|
+
else if (this.selected) this.done(this.selected)
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
this.search.handleInput(data)
|
|
95
|
+
if (this.search.getValue() !== this.query) {
|
|
96
|
+
this.query = this.search.getValue()
|
|
97
|
+
this.groupFocus = false
|
|
98
|
+
this.search.focused = this.focusedValue && !!this.query
|
|
99
|
+
const items = this.visibleItems()
|
|
100
|
+
this.select(items.find(item => item.id === this.selected) ?? items[0])
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
render(width: number): string[] {
|
|
105
|
+
const height = Math.max(0, Math.trunc(this.tui.terminal.rows))
|
|
106
|
+
width = Math.max(0, Math.trunc(width))
|
|
107
|
+
if (!width || !height) return []
|
|
108
|
+
const theme = this.theme()
|
|
109
|
+
const inner = Math.max(0, width - 4)
|
|
110
|
+
const clip = (value: string, columns = width) => truncateToWidth(value, columns, '')
|
|
111
|
+
const pad = (value: string, columns: number) => clip(value, columns) + ' '.repeat(Math.max(0, columns - Math.min(columns, visibleWidth(value))))
|
|
112
|
+
const border = (value: string) => theme.fg('border', value)
|
|
113
|
+
const row = (value: string) => clip(border('│') + ' ' + pad(value, inner) + ' ' + border('│'))
|
|
114
|
+
const rule = () => clip(border('├' + '─'.repeat(Math.max(0, width - 2)) + '┤'))
|
|
115
|
+
const title = clip(' Settings ', Math.max(0, width - 4))
|
|
116
|
+
const top = clip(border('╭─') + theme.bold(theme.fg('accent', title)) + border('─'.repeat(Math.max(0, width - visibleWidth(title) - 3)) + '╮'))
|
|
117
|
+
const bottom = clip(border('╰' + '─'.repeat(Math.max(0, width - 2)) + '╯'))
|
|
118
|
+
const tabRow = (compact: boolean) => this.tabs.map(tab => {
|
|
119
|
+
const label = ` ${categoryIcon[tab]}${compact ? '' : ` ${categoryName(tab)}`} `
|
|
120
|
+
return tab === this.currentTab ? theme.bg('selectedBg', theme.fg('accent', label)) : theme.fg('muted', label)
|
|
121
|
+
}).join(' ')
|
|
122
|
+
let tabs = tabRow(false)
|
|
123
|
+
if (visibleWidth(tabs) > inner) tabs = tabRow(true)
|
|
124
|
+
if (visibleWidth(tabs) > inner) tabs = theme.fg('accent', `‹ ${categoryIcon[this.currentTab] ?? ''} ${categoryName(this.currentTab)} ›`)
|
|
125
|
+
const lines = [top, row(tabs), rule()]
|
|
126
|
+
const items = this.visibleItems()
|
|
127
|
+
const current = items.find(item => item.id === this.selected)
|
|
128
|
+
if (this.query) {
|
|
129
|
+
const count = `${items.length} ${items.length === 1 ? 'match' : 'matches'}`
|
|
130
|
+
const prefix = theme.fg('accent', '🔍 ')
|
|
131
|
+
const queryWidth = Math.max(2, inner - visibleWidth(prefix) - count.length - 1)
|
|
132
|
+
lines.push(row(prefix + (this.search.render(queryWidth)[0] ?? '') + ' ' + theme.fg('dim', count)))
|
|
133
|
+
}
|
|
134
|
+
const contentRows = Math.max(0, height - lines.length - 3)
|
|
135
|
+
const descriptionRows = Math.min(3, Math.max(0, contentRows - 2))
|
|
136
|
+
const listRows = Math.max(0, contentRows - descriptionRows - (descriptionRows ? 1 : 0))
|
|
137
|
+
const groups = [...new Set(items.map(item => item.group))]
|
|
138
|
+
// The shipped SettingsList keeps at least 60 columns for editable rows.
|
|
139
|
+
const sidebar = !this.query && groups.length > 1 && inner - 28 >= 60 ? 26 : 0
|
|
140
|
+
const paneWidth = Math.max(0, inner - (sidebar ? sidebar + 2 : 0))
|
|
141
|
+
const entries: { item?: TerminalSettingItem; heading?: string }[] = []
|
|
142
|
+
let lastGroup: string | undefined
|
|
143
|
+
for (const item of items) {
|
|
144
|
+
const group = this.query ? `${categoryName(tabOf(item))} / ${item.group}` : item.group
|
|
145
|
+
if (group !== lastGroup) { entries.push({ heading: group }); lastGroup = group }
|
|
146
|
+
entries.push({ item })
|
|
147
|
+
}
|
|
148
|
+
const selectedRow = entries.findIndex(entry => entry.item?.id === this.selected)
|
|
149
|
+
const start = Math.max(0, Math.min(Math.max(0, entries.length - listRows), selectedRow - Math.floor(listRows / 2)))
|
|
150
|
+
const maxLabel = Math.min(30, Math.max(0, ...items.map(item => visibleWidth(plain(item.label)))))
|
|
151
|
+
const labelWidth = Math.min(maxLabel, Math.max(0, Math.floor((paneWidth - 4) * 0.6)))
|
|
152
|
+
for (let index = 0; index < listRows; index++) {
|
|
153
|
+
const entry = entries[start + index]
|
|
154
|
+
let right = ''
|
|
155
|
+
if (entry?.heading) right = theme.fg(entry.heading.includes(current?.group ?? '\u0000') ? 'accent' : 'dim', plain(entry.heading))
|
|
156
|
+
if (entry?.item) {
|
|
157
|
+
const item = entry.item
|
|
158
|
+
const selected = item.id === this.selected && !this.groupFocus
|
|
159
|
+
const active = this.query || item.group === current?.group
|
|
160
|
+
const prefix = selected ? '❯ ' : ' '
|
|
161
|
+
const labelText = prefix + pad(plain(item.label), labelWidth)
|
|
162
|
+
const text = labelText + ' ' + clip(plain(item.value), Math.max(0, paneWidth - visibleWidth(labelText) - 2))
|
|
163
|
+
right = selected
|
|
164
|
+
? theme.bg('selectedBg', theme.fg('accent', pad(text, paneWidth)))
|
|
165
|
+
: theme.fg(active ? 'text' : 'dim', text)
|
|
166
|
+
}
|
|
167
|
+
if (!entries.length && index === 0) right = theme.fg('warning', this.query ? 'No matching settings' : 'No settings')
|
|
168
|
+
if (sidebar) {
|
|
169
|
+
const name = groups[index]
|
|
170
|
+
const active = name === current?.group
|
|
171
|
+
const left = name ? (active && this.groupFocus ? '❯ ' : ' ') + plain(name) : ''
|
|
172
|
+
const styled = active && this.groupFocus
|
|
173
|
+
? theme.bg('selectedBg', theme.fg('accent', pad(left, sidebar)))
|
|
174
|
+
: theme.fg(active ? 'accent' : 'dim', pad(left, sidebar))
|
|
175
|
+
lines.push(row(styled + theme.fg('dim', '│ ') + right))
|
|
176
|
+
} else lines.push(row(right))
|
|
177
|
+
}
|
|
178
|
+
if (descriptionRows) lines.push(row(''))
|
|
179
|
+
const description = current?.description && inner > 1 ? wrapTextWithAnsi(plain(current.description), inner) : []
|
|
180
|
+
for (let index = 0; index < descriptionRows; index++) lines.push(row(theme.fg('muted', description[index] ?? '')))
|
|
181
|
+
const footer = this.groupFocus
|
|
182
|
+
? '↑/↓ jump sections · Tab/Enter to settings · ←/→ switch tabs · Esc close'
|
|
183
|
+
: this.query ? 'Enter/Space change · Tab jump tabs · Esc close'
|
|
184
|
+
: `Enter/Space change · ${groups.length > 1 ? 'Tab jump sections · ←/→ switch tabs' : 'Tab/←/→ switch tabs'} · Type to search · Esc close`
|
|
185
|
+
lines.push(rule(), row(theme.fg('dim', footer)), bottom)
|
|
186
|
+
return lines.slice(0, height).map(value => clip(value))
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { initTheme, Theme } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { stripTerminalSequences, visibleWidth } from '@earendil-works/pi-tui'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { contextGauge, displayPath, renderStatusBand, type BandState } from './status'
|
|
6
|
+
import { createHarnessTheme } from './theme'
|
|
7
|
+
|
|
8
|
+
initTheme('dark', false)
|
|
9
|
+
const theme = createHarnessTheme({ getColorMode: () => 'truecolor' } as Theme)
|
|
10
|
+
const state: BandState = {
|
|
11
|
+
cwd: '/work/project', model: { name: 'Claude Sonnet 4.6', reasoning: true, contextWindow: 200_000 },
|
|
12
|
+
thinkingLevel: 'high', branch: 'main', contextPercent: 25, contextWindow: 200_000,
|
|
13
|
+
costUsd: 0.15, subscription: false, running: false, elapsedMs: 0
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('Oh My Pi status band', () => {
|
|
17
|
+
it('renders the actual model, effort, branch, cost, and embedded context gauge', () => {
|
|
18
|
+
const line = renderStatusBand(state, 120, theme)
|
|
19
|
+
const plain = stripTerminalSequences(line)
|
|
20
|
+
expect(plain).toContain('π > ◒ Sonnet 4.6 > 📁 /work/project > ⑂ main > $0.15 ▶')
|
|
21
|
+
expect(plain).toContain('25%')
|
|
22
|
+
expect(plain).toMatch(/200K─$/)
|
|
23
|
+
expect(visibleWidth(line)).toBe(120)
|
|
24
|
+
expect(line).toContain('48;2;15;18;22')
|
|
25
|
+
expect(line).toContain('38;2;0;180;255')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('stays within every narrow and wide terminal with wide, colored, and untrusted metadata', () => {
|
|
29
|
+
for (let width = 1; width <= 180; width++) {
|
|
30
|
+
const line = renderStatusBand({ ...state,
|
|
31
|
+
cwd: '/work/日本語/long-project-name/with-many-parts',
|
|
32
|
+
branch: '\x1b]2;malicious title\x07main\nother',
|
|
33
|
+
model: { ...state.model!, name: '非常に長い名前'.repeat(20) }
|
|
34
|
+
}, width, theme)
|
|
35
|
+
expect(visibleWidth(line), `width ${width}`).toBeLessThanOrEqual(width)
|
|
36
|
+
expect(line).not.toContain('malicious title')
|
|
37
|
+
expect(line).not.toContain('\n')
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('never invents model, quota, usage, or git data while startup context is unknown', () => {
|
|
42
|
+
const plain = stripTerminalSequences(renderStatusBand({ ...state,
|
|
43
|
+
model: undefined, branch: null, contextWindow: 0, contextPercent: null, costUsd: 0
|
|
44
|
+
}, 80, theme))
|
|
45
|
+
expect(plain).toContain('⬢ no-model')
|
|
46
|
+
expect(plain).not.toMatch(/0%|200K|main|\$|quota|plan|collab/i)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('clamps gauge values and omits an unknown percentage while retaining the window', () => {
|
|
50
|
+
for (const percent of [null, -20, 0, 50, 100, 120, NaN]) {
|
|
51
|
+
for (const width of [0, 1, 5, 16, 60]) {
|
|
52
|
+
const gauge = contextGauge(width, percent, 200_000, theme)
|
|
53
|
+
expect(visibleWidth(gauge)).toBe(width)
|
|
54
|
+
expect(stripTerminalSequences(gauge)).not.toContain('NaN')
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
expect(stripTerminalSequences(contextGauge(20, null, 200_000, theme))).not.toContain('%')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('keeps sibling directories outside home and shows existing subscription and overflow states', () => {
|
|
61
|
+
expect(displayPath('/home/test/project', '/home/test')).toBe('~/project')
|
|
62
|
+
expect(displayPath('/home/test-other/project', '/home/test')).toBe('/home/test-other/project')
|
|
63
|
+
const line = stripTerminalSequences(renderStatusBand({ ...state,
|
|
64
|
+
subscription: true, costUsd: 0, running: true, elapsedMs: 65_000, hiddenAbove: 4
|
|
65
|
+
}, 160, theme))
|
|
66
|
+
expect(line).toContain('1m 5s')
|
|
67
|
+
expect(line).toContain('$0.00 (sub)')
|
|
68
|
+
expect(line).toContain('↑ 4 more')
|
|
69
|
+
})
|
|
70
|
+
})
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Presentation adapted from Oh My Pi 18.11.3's band composer and status line.
|
|
2
|
+
// Upstream MIT license and evidence are recorded in NOTICE.md and docs/terminal-tui.md.
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { isAbsolute, relative, sep } from 'node:path'
|
|
5
|
+
import type { Theme } from '@earendil-works/pi-coding-agent'
|
|
6
|
+
import { parseColor, stripTerminalSequences, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'
|
|
7
|
+
|
|
8
|
+
import { TITANIUM } from './theme'
|
|
9
|
+
|
|
10
|
+
export interface BandState {
|
|
11
|
+
cwd: string
|
|
12
|
+
model?: { name: string; reasoning: boolean; contextWindow: number }
|
|
13
|
+
thinkingLevel: string
|
|
14
|
+
branch: string | null
|
|
15
|
+
account?: string
|
|
16
|
+
contextPercent: number | null
|
|
17
|
+
contextWindow: number
|
|
18
|
+
costUsd: number
|
|
19
|
+
subscription: boolean
|
|
20
|
+
running: boolean
|
|
21
|
+
elapsedMs: number
|
|
22
|
+
hiddenAbove?: number
|
|
23
|
+
hiddenBelow?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// OMP's shipped light theme supplies status colors separately from transcript colors.
|
|
27
|
+
const LIGHT_STATUS = {
|
|
28
|
+
...TITANIUM, darkTitanium: '#e0e0e0', electricBlue: '#875f87', subtleGray: '#808080',
|
|
29
|
+
brightAluminum: '#005f87', readoutGreen: '#005f00', titaniumGold: '#005f5f'
|
|
30
|
+
}
|
|
31
|
+
const palette = (theme: Theme) => theme.appearance === 'light' ? LIGHT_STATUS : TITANIUM
|
|
32
|
+
|
|
33
|
+
const EFFORT: Record<string, string> = { off: '⦸', minimal: '○', low: '◔', medium: '◑', high: '◒', xhigh: '◕', max: '◉' }
|
|
34
|
+
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
35
|
+
|
|
36
|
+
export function singleLine(text: string): string {
|
|
37
|
+
return stripTerminalSequences(text).replace(/[\x00-\x1f\x7f-\x9f]/g, ' ').replace(/ +/g, ' ').trim()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function displayPath(cwd: string, home = homedir()): string {
|
|
41
|
+
const inside = relative(home, cwd)
|
|
42
|
+
if (inside === '') return '~'
|
|
43
|
+
return inside !== '..' && !inside.startsWith(`..${sep}`) && !isAbsolute(inside) ? `~${sep}${inside}` : cwd
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function formatContextWindow(tokens: number): string {
|
|
47
|
+
if (tokens >= 1_000_000) return `${Number((tokens / 1_000_000).toFixed(1))}M`
|
|
48
|
+
if (tokens >= 1000) return `${Number((tokens / 1000).toFixed(1))}K`
|
|
49
|
+
return String(Math.max(0, Math.round(tokens)))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function elapsed(milliseconds: number): string {
|
|
53
|
+
const seconds = Math.max(0, Math.floor(milliseconds / 1000))
|
|
54
|
+
return seconds >= 60 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${seconds}s`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function color(theme: Theme, hex: string, text: string): string {
|
|
58
|
+
return theme.style(text, { fg: parseColor(hex) })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function contextGauge(width: number, percent: number | null, window: number, theme: Theme): string {
|
|
62
|
+
const colors = palette(theme)
|
|
63
|
+
const size = Math.max(0, Math.floor(width))
|
|
64
|
+
if (!size) return ''
|
|
65
|
+
const bounded = percent === null || !Number.isFinite(percent) ? null : Math.max(0, Math.min(100, percent))
|
|
66
|
+
const used = window > 0 ? Math.min(size, Math.max(1, Math.round((bounded ?? 0) / 100 * size))) : 0
|
|
67
|
+
const cells = Array.from({ length: size }, () => '─')
|
|
68
|
+
const percentLabel = bounded === null ? '' : `${Math.round(bounded)}%`
|
|
69
|
+
const windowLabel = window > 0 ? formatContextWindow(window) : ''
|
|
70
|
+
const end = size - windowLabel.length - 1
|
|
71
|
+
const start = Math.min(used, Math.max(1, end - percentLabel.length - 1))
|
|
72
|
+
if (percentLabel && start + percentLabel.length < end) cells.splice(start, percentLabel.length, ...percentLabel)
|
|
73
|
+
if (windowLabel && end > percentLabel.length + 2) cells.splice(end, windowLabel.length, ...windowLabel)
|
|
74
|
+
return cells.map((cell, index) => color(theme, index < used ? colors.electricBlue : colors.subtleGray, cell)).join('')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function renderStatusBand(state: BandState, width: number, theme: Theme): string {
|
|
78
|
+
const colors = palette(theme)
|
|
79
|
+
const size = Math.max(0, Math.floor(width))
|
|
80
|
+
if (!size) return ''
|
|
81
|
+
const model = state.model
|
|
82
|
+
const effort = model?.reasoning ? EFFORT[state.thinkingLevel] ?? '○' : '⬢'
|
|
83
|
+
const name = model ? singleLine(model.name).replace(/^Claude /, '') : 'no-model'
|
|
84
|
+
const modelText = color(theme, colors.electricBlue, `${effort} ${name}`)
|
|
85
|
+
const brand = color(theme, state.running ? colors.electricBlue : '#6b7280', state.running
|
|
86
|
+
? `${SPINNER[Math.floor(state.elapsedMs / 80) % SPINNER.length]} ${elapsed(state.elapsedMs)}` : 'π')
|
|
87
|
+
const path = singleLine(displayPath(state.cwd))
|
|
88
|
+
const separator = ` ${color(theme, colors.subtleGray, '>')} `
|
|
89
|
+
const account = state.account ? color(theme, colors.brightAluminum, singleLine(state.account)) : null
|
|
90
|
+
const branch = state.branch ? color(theme, colors.readoutGreen, `⑂ ${singleLine(state.branch)}`) : null
|
|
91
|
+
const cost = state.costUsd > 0 || state.subscription
|
|
92
|
+
? color(theme, colors.titaniumGold, `$${state.costUsd.toFixed(2)}${state.subscription ? ' (sub)' : ''}`) : null
|
|
93
|
+
const overflow = [state.hiddenAbove ? `↑ ${state.hiddenAbove} more` : '', state.hiddenBelow ? `↓ ${state.hiddenBelow} more` : ''].filter(Boolean).join(' ')
|
|
94
|
+
const extra = overflow ? color(theme, '#6b7280', overflow) : null
|
|
95
|
+
let pathWidth = Math.min(40, visibleWidth(path))
|
|
96
|
+
let parts = [brand, modelText, color(theme, colors.brightAluminum, `📁 ${truncateToWidth(path, pathWidth)}`), account, branch, cost, extra].filter((part): part is string => part !== null)
|
|
97
|
+
const minimumGauge = state.contextWindow > 0 ? Math.min(12, Math.floor(size / 3)) : 1
|
|
98
|
+
const groupSize = () => visibleWidth(parts.join(separator)) + 3
|
|
99
|
+
while (groupSize() + minimumGauge > size && pathWidth > 8) {
|
|
100
|
+
pathWidth -= 1
|
|
101
|
+
parts[2] = color(theme, colors.brightAluminum, `📁 ${truncateToWidth(path, pathWidth)}`)
|
|
102
|
+
}
|
|
103
|
+
while (groupSize() + minimumGauge > size && parts.length > 2) parts.pop()
|
|
104
|
+
if (groupSize() + minimumGauge > size) parts = [modelText]
|
|
105
|
+
const group = truncateToWidth(` ${parts.join(separator)} `, Math.max(0, size - 1), '', false)
|
|
106
|
+
const cap = color(theme, colors.darkTitanium, '▶')
|
|
107
|
+
const gauge = contextGauge(Math.max(0, size - visibleWidth(group) - 1), state.contextPercent, state.contextWindow, theme)
|
|
108
|
+
return theme.style(group, { bg: parseColor(colors.darkTitanium) }) + cap + gauge
|
|
109
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Adapted from @oh-my-pi/pi-tui 18.11.3 (https://github.com/can1357/oh-my-pi).
|
|
3
|
+
*
|
|
4
|
+
* MIT License
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) 2025 Mario Zechner
|
|
7
|
+
* Copyright (c) 2025-2026 Can Bölük
|
|
8
|
+
* Copyright (c) 2026 Stencil Labs, Inc.
|
|
9
|
+
*
|
|
10
|
+
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
* of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
* in the Software without restriction, including without limitation the rights
|
|
13
|
+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
* copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
* furnished to do so, subject to the following conditions:
|
|
16
|
+
*
|
|
17
|
+
* The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
* copies or substantial portions of the Software.
|
|
19
|
+
*
|
|
20
|
+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
* SOFTWARE.
|
|
27
|
+
*/
|
|
28
|
+
import { Theme, type ThemeBg, type ThemeColor } from '@earendil-works/pi-coding-agent'
|
|
29
|
+
|
|
30
|
+
export const TITANIUM = {
|
|
31
|
+
brushedTitanium: '#151820',
|
|
32
|
+
darkTitanium: '#0f1216',
|
|
33
|
+
electricBlue: '#00b4ff',
|
|
34
|
+
deepBlue: '#0082b3',
|
|
35
|
+
titaniumGold: '#d4c090',
|
|
36
|
+
brightAluminum: '#e8ecf4',
|
|
37
|
+
dimAluminum: '#9ca3b0',
|
|
38
|
+
warningAmber: '#ffb347',
|
|
39
|
+
readoutGreen: '#00ff88',
|
|
40
|
+
alertRed: '#ff4757',
|
|
41
|
+
subtleGray: '#2a3038'
|
|
42
|
+
} as const
|
|
43
|
+
|
|
44
|
+
const foreground = {
|
|
45
|
+
accent: '#00b4ff',
|
|
46
|
+
border: '#2a3038',
|
|
47
|
+
borderAccent: '#00b4ff',
|
|
48
|
+
borderMuted: '#1f252d',
|
|
49
|
+
success: '#00ff88',
|
|
50
|
+
error: '#ff4757',
|
|
51
|
+
warning: '#ffb347',
|
|
52
|
+
muted: '#9ca3b0',
|
|
53
|
+
dim: '#6b7280',
|
|
54
|
+
text: '',
|
|
55
|
+
thinkingText: '#9ca3b0',
|
|
56
|
+
userMessageText: '',
|
|
57
|
+
customMessageText: '',
|
|
58
|
+
customMessageLabel: '#d4c090',
|
|
59
|
+
toolTitle: '',
|
|
60
|
+
toolOutput: '#9ca3b0',
|
|
61
|
+
mdHeading: '#00b4ff',
|
|
62
|
+
mdLink: '#00b4ff',
|
|
63
|
+
mdLinkUrl: '#0082b3',
|
|
64
|
+
mdCode: '#00ff88',
|
|
65
|
+
mdCodeBlock: '#9ca3b0',
|
|
66
|
+
mdCodeBlockBorder: '#2a3038',
|
|
67
|
+
mdQuote: '#9ca3b0',
|
|
68
|
+
mdQuoteBorder: '#2a3038',
|
|
69
|
+
mdHr: '#2a3038',
|
|
70
|
+
mdListBullet: '#00b4ff',
|
|
71
|
+
toolDiffAdded: '#00ff88',
|
|
72
|
+
toolDiffRemoved: '#ff4757',
|
|
73
|
+
toolDiffContext: '#9ca3b0',
|
|
74
|
+
syntaxComment: '#6b7280',
|
|
75
|
+
syntaxKeyword: '#00b4ff',
|
|
76
|
+
syntaxFunction: '#00ff88',
|
|
77
|
+
syntaxVariable: '#e8ecf4',
|
|
78
|
+
syntaxString: '#d4c090',
|
|
79
|
+
syntaxNumber: '#ffb347',
|
|
80
|
+
syntaxType: '#00b4ff',
|
|
81
|
+
syntaxOperator: '#00b4ff',
|
|
82
|
+
syntaxPunctuation: '#9ca3b0',
|
|
83
|
+
thinkingOff: '#4a5058',
|
|
84
|
+
thinkingMinimal: '#5a6068',
|
|
85
|
+
thinkingLow: '#6a7078',
|
|
86
|
+
thinkingMedium: '#9ca3b0',
|
|
87
|
+
thinkingHigh: '#00b4ff',
|
|
88
|
+
thinkingXhigh: '#d4c090',
|
|
89
|
+
bashMode: '#00ff88',
|
|
90
|
+
scrollbarTrack: '#2a3038',
|
|
91
|
+
scrollbarThumb: '#9ca3b0',
|
|
92
|
+
searchMatchText: '#e8ecf4',
|
|
93
|
+
thinkingMax: '#d4c090'
|
|
94
|
+
} satisfies Record<ThemeColor, string>
|
|
95
|
+
|
|
96
|
+
const background = {
|
|
97
|
+
selectedBg: '#0082b3',
|
|
98
|
+
searchMatchBg: '#0082b3',
|
|
99
|
+
userMessageBg: '#0f1216',
|
|
100
|
+
customMessageBg: '#2a3038',
|
|
101
|
+
toolPendingBg: '#0f1216',
|
|
102
|
+
toolSuccessBg: '#0f1216',
|
|
103
|
+
toolErrorBg: '#1a0f10'
|
|
104
|
+
} satisfies Record<ThemeBg, string>
|
|
105
|
+
|
|
106
|
+
export function createHarnessTheme(base: Theme): Theme {
|
|
107
|
+
return new Theme(foreground, background, base.getColorMode(), { name: 'harness-titanium', appearance: 'dark' })
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const lightForeground = {
|
|
111
|
+
accent: '#5a8080',
|
|
112
|
+
border: '#547da7',
|
|
113
|
+
borderAccent: '#5a8080',
|
|
114
|
+
borderMuted: '#b0b0b0',
|
|
115
|
+
success: '#588458',
|
|
116
|
+
error: '#aa5555',
|
|
117
|
+
warning: '#9a7326',
|
|
118
|
+
muted: '#6c6c6c',
|
|
119
|
+
dim: '#767676',
|
|
120
|
+
text: '',
|
|
121
|
+
thinkingText: '#6c6c6c',
|
|
122
|
+
userMessageText: '',
|
|
123
|
+
customMessageText: '',
|
|
124
|
+
customMessageLabel: '#7e57c2',
|
|
125
|
+
toolTitle: '',
|
|
126
|
+
toolOutput: '#6c6c6c',
|
|
127
|
+
mdHeading: '#9a7326',
|
|
128
|
+
mdLink: '#547da7',
|
|
129
|
+
mdLinkUrl: '#767676',
|
|
130
|
+
mdCode: '#5a8080',
|
|
131
|
+
mdCodeBlock: '#588458',
|
|
132
|
+
mdCodeBlockBorder: '#6c6c6c',
|
|
133
|
+
mdQuote: '#6c6c6c',
|
|
134
|
+
mdQuoteBorder: '#6c6c6c',
|
|
135
|
+
mdHr: '#6c6c6c',
|
|
136
|
+
mdListBullet: '#588458',
|
|
137
|
+
toolDiffAdded: '#588458',
|
|
138
|
+
toolDiffRemoved: '#aa5555',
|
|
139
|
+
toolDiffContext: '#6c6c6c',
|
|
140
|
+
syntaxComment: '#008000',
|
|
141
|
+
syntaxKeyword: '#0000FF',
|
|
142
|
+
syntaxFunction: '#795E26',
|
|
143
|
+
syntaxVariable: '#001080',
|
|
144
|
+
syntaxString: '#A31515',
|
|
145
|
+
syntaxNumber: '#098658',
|
|
146
|
+
syntaxType: '#267F99',
|
|
147
|
+
syntaxOperator: '#000000',
|
|
148
|
+
syntaxPunctuation: '#000000',
|
|
149
|
+
thinkingOff: '#b0b0b0',
|
|
150
|
+
thinkingMinimal: '#767676',
|
|
151
|
+
thinkingLow: '#547da7',
|
|
152
|
+
thinkingMedium: '#5a8080',
|
|
153
|
+
thinkingHigh: '#875f87',
|
|
154
|
+
thinkingXhigh: '#8b008b',
|
|
155
|
+
bashMode: '#588458',
|
|
156
|
+
scrollbarTrack: '#b0b0b0',
|
|
157
|
+
scrollbarThumb: '#6c6c6c',
|
|
158
|
+
searchMatchText: '#3b3b3b',
|
|
159
|
+
thinkingMax: '#8b008b'
|
|
160
|
+
} satisfies Record<ThemeColor, string>
|
|
161
|
+
|
|
162
|
+
const lightBackground = {
|
|
163
|
+
selectedBg: '#d0d0e0',
|
|
164
|
+
searchMatchBg: '#d0d0e0',
|
|
165
|
+
userMessageBg: '#e8e8e8',
|
|
166
|
+
customMessageBg: '#ede7f6',
|
|
167
|
+
toolPendingBg: '#e8e8f0',
|
|
168
|
+
toolSuccessBg: '#e8f0e8',
|
|
169
|
+
toolErrorBg: '#f0e8e8'
|
|
170
|
+
} satisfies Record<ThemeBg, string>
|
|
171
|
+
|
|
172
|
+
export function createHarnessLightTheme(base: Theme): Theme {
|
|
173
|
+
return new Theme(lightForeground, lightBackground, base.getColorMode(), { name: 'light', appearance: 'light' })
|
|
174
|
+
}
|