@xingular/harness 2.0.0 → 2.1.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/CHANGELOG.md +6 -0
- package/README.md +7 -3
- package/dist/cli.js +8639 -8147
- package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +3 -1
- package/docs/terminal-tui.md +14 -8
- package/package.json +1 -1
- package/src/hub/hub.test.ts +49 -1
- package/src/hub/hub.ts +122 -6
- package/src/hub/schema.ts +13 -1
- package/src/server/methods.ts +2 -0
- package/src/sessions/planMode.test.ts +40 -0
- package/src/sessions/planMode.ts +57 -0
- package/src/sessions/sessions.test.ts +23 -0
- package/src/sessions/sessions.ts +17 -1
- package/src/terminal/tui/application.test.ts +25 -4
- package/src/terminal/tui/application.ts +13 -5
- package/src/terminal/tui/commands.test.ts +1 -0
- package/src/terminal/tui/commands.ts +10 -0
- package/src/terminal/tui/editor.ts +16 -1
- package/src/terminal/tui/keybindings.test.ts +1 -2
- package/src/terminal/tui/keybindings.ts +1 -2
- package/src/terminal/tui/planMode.ts +2 -12
- package/src/tools/types.ts +2 -0
|
@@ -184,6 +184,7 @@ export class TerminalApplication {
|
|
|
184
184
|
session: () => this.session, runtime: () => this.runtime!, ui: this.ui,
|
|
185
185
|
cwd: () => this.runtime!.cwd, sessionDirectory: () => this.runtime!.sessionDirectory,
|
|
186
186
|
refresh: () => this.refresh(), shutdown: () => { void this.shutdown() }, setEditorText: text => { this.editor.setText(text); this.syncDraft(text) },
|
|
187
|
+
planMode: () => this.planMode, setPlanMode: enabled => this.setPlanMode(enabled),
|
|
187
188
|
copyText: copyToClipboard, openExternal,
|
|
188
189
|
changed: this.change,
|
|
189
190
|
trajectory: {
|
|
@@ -307,8 +308,9 @@ export class TerminalApplication {
|
|
|
307
308
|
this.phase = undefined
|
|
308
309
|
}
|
|
309
310
|
/** Plan mode narrows the session to read-only tools; leaving it restores the set the session had before. */
|
|
310
|
-
private
|
|
311
|
+
private setPlanMode(enabled: boolean): void {
|
|
311
312
|
if (!this.runtime) return
|
|
313
|
+
if (this.planMode === enabled) return
|
|
312
314
|
if (this.planMode) {
|
|
313
315
|
this.session.setActiveToolsByName(this.normalTools ?? this.session.getActiveToolNames())
|
|
314
316
|
this.normalTools = undefined
|
|
@@ -316,8 +318,8 @@ export class TerminalApplication {
|
|
|
316
318
|
this.normalTools = this.session.getActiveToolNames()
|
|
317
319
|
this.session.setActiveToolsByName(planModeTools(this.normalTools))
|
|
318
320
|
}
|
|
319
|
-
this.planMode =
|
|
320
|
-
this.notify(this.planMode ? 'Plan mode: only read-only tools are available.
|
|
321
|
+
this.planMode = enabled
|
|
322
|
+
this.notify(this.planMode ? 'Plan mode: only read-only tools are available. Use /plan off to return to normal.' : 'Normal mode: all tools are available.')
|
|
321
323
|
this.change()
|
|
322
324
|
}
|
|
323
325
|
private scrollToLatest(): void { this.viewport?.scrollToEnd() }
|
|
@@ -446,7 +448,6 @@ export class TerminalApplication {
|
|
|
446
448
|
else void this.shutdown()
|
|
447
449
|
} else if (is('app.exit') && this.editor.getText() === '') void this.shutdown()
|
|
448
450
|
else if (is('app.thinking.cycle')) this.session.cycleThinkingLevel()
|
|
449
|
-
else if (is('app.plan.toggle')) this.togglePlanMode()
|
|
450
451
|
else if (is('app.thinking.increase') || is('app.thinking.decrease')) this.stepThinking(is('app.thinking.increase') ? 1 : -1)
|
|
451
452
|
else if (is('app.thinking.save')) this.session.setThinkingLevel(this.session.thinkingLevel, { persist: true })
|
|
452
453
|
else if (is('app.model.cycleForward') || is('app.model.cycleBackward')) this.attempt(() => this.session.cycleModel(is('app.model.cycleBackward') ? 'backward' : 'forward'))
|
|
@@ -513,7 +514,14 @@ export class TerminalApplication {
|
|
|
513
514
|
const text = typeof message.content === 'string' ? message.content : message.content.filter(part => part.type === 'text').map(part => part.text).join('\n')
|
|
514
515
|
if (text) this.editor.addToHistory?.(text)
|
|
515
516
|
}
|
|
516
|
-
|
|
517
|
+
const commands = this.commands.completions()
|
|
518
|
+
if (this.editor instanceof HarnessEditor) {
|
|
519
|
+
this.editor.setValidCommands([
|
|
520
|
+
...commands.map(command => command.name),
|
|
521
|
+
...this.commands.skills().map(skill => 'skill:' + skill.name)
|
|
522
|
+
])
|
|
523
|
+
}
|
|
524
|
+
this.baseAutocomplete = skillCompletions(new CombinedAutocompleteProvider(commands, this.runtime.cwd), this.commands.skills)
|
|
517
525
|
this.editor.setAutocompleteProvider?.(this.ui.wrapAutocomplete(this.baseAutocomplete))
|
|
518
526
|
this.change()
|
|
519
527
|
}
|
|
@@ -31,6 +31,7 @@ function setup() {
|
|
|
31
31
|
session: () => session, runtime: () => runtime as unknown as ReturnType<TerminalCommandHost['runtime']>, ui,
|
|
32
32
|
cwd: () => '/isolated/project', sessionDirectory: () => '/isolated/account/history',
|
|
33
33
|
refresh: vi.fn(), changed: vi.fn(), shutdown: vi.fn(), setEditorText: vi.fn(), copyText: vi.fn(async () => {}),
|
|
34
|
+
planMode: () => false, setPlanMode: vi.fn(),
|
|
34
35
|
hotkeys: () => 'Escape: interrupt', changelog: async () => 'Harness release notes',
|
|
35
36
|
trajectory: {
|
|
36
37
|
list: vi.fn(async () => [trajectoryRun]),
|
|
@@ -21,6 +21,8 @@ export interface TerminalCommandHost {
|
|
|
21
21
|
sessionDirectory(): string
|
|
22
22
|
refresh(): void
|
|
23
23
|
changed(): void
|
|
24
|
+
planMode(): boolean
|
|
25
|
+
setPlanMode(enabled: boolean): void
|
|
24
26
|
shutdown(): void
|
|
25
27
|
setEditorText(text: string): void
|
|
26
28
|
copyText(text: string): Promise<void>
|
|
@@ -39,6 +41,7 @@ export interface TerminalCommandCompletion { name: string; description?: string
|
|
|
39
41
|
|
|
40
42
|
const BUILTINS: TerminalCommandCompletion[] = [
|
|
41
43
|
{ name: 'help', description: 'Available terminal commands' },
|
|
44
|
+
{ name: 'plan', description: 'Toggle read-only plan mode' },
|
|
42
45
|
{ name: 'new', description: 'Start a new session' },
|
|
43
46
|
{ name: 'resume', description: 'Resume a session in this account and folder' },
|
|
44
47
|
{ name: 'tree', description: 'Navigate the session tree' },
|
|
@@ -286,6 +289,13 @@ export function createTerminalCommands(host: TerminalCommandHost) {
|
|
|
286
289
|
const session = host.session()
|
|
287
290
|
switch (name) {
|
|
288
291
|
case 'help': notify(completions().map(item => `/${item.name} ${item.description ?? ''}`).join('\n')); break
|
|
292
|
+
case 'plan': {
|
|
293
|
+
const current = host.planMode()
|
|
294
|
+
const next = !argument || argument === 'toggle' ? !current : argument === 'on' ? true : argument === 'off' ? false : null
|
|
295
|
+
if (next === null) throw new Error('Use /plan, /plan on, /plan off, or /plan toggle.')
|
|
296
|
+
host.setPlanMode(next)
|
|
297
|
+
break
|
|
298
|
+
}
|
|
289
299
|
case 'quit': host.shutdown(); break
|
|
290
300
|
case 'new': await host.runtime().newSession(); break
|
|
291
301
|
case 'resume': {
|
|
@@ -15,6 +15,12 @@ export const COMPOSER_SUGGESTIONS = [
|
|
|
15
15
|
'write a test for the next change'
|
|
16
16
|
]
|
|
17
17
|
|
|
18
|
+
function leadingSlashCommand(text: string, validCommands: ReadonlySet<string>): string | null {
|
|
19
|
+
const match = /^\s*\/([^\s/]+)(?=\s|$)/u.exec(text)
|
|
20
|
+
if (!match || !validCommands.has(match[1]!)) return null
|
|
21
|
+
return '/' + match[1]
|
|
22
|
+
}
|
|
23
|
+
|
|
18
24
|
export interface ComposerView {
|
|
19
25
|
band(width: number, hiddenAbove: number, hiddenBelow: number): string
|
|
20
26
|
theme(): Theme
|
|
@@ -27,11 +33,17 @@ export class HarnessEditor extends CustomEditor {
|
|
|
27
33
|
private contentRows = 1
|
|
28
34
|
private completionRows = 0
|
|
29
35
|
private gutterWidth = 2
|
|
36
|
+
private validCommands = new Set<string>()
|
|
30
37
|
|
|
31
38
|
constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, private readonly view: ComposerView) {
|
|
32
39
|
super(tui, theme, keybindings, { paddingX: 0 })
|
|
33
40
|
}
|
|
34
41
|
|
|
42
|
+
setValidCommands(commands: readonly string[]): void {
|
|
43
|
+
this.validCommands = new Set(commands)
|
|
44
|
+
this.tui.requestRender()
|
|
45
|
+
}
|
|
46
|
+
|
|
35
47
|
override setPaddingX(_padding: number): void {
|
|
36
48
|
super.setPaddingX(0)
|
|
37
49
|
}
|
|
@@ -58,10 +70,13 @@ export class HarnessEditor extends CustomEditor {
|
|
|
58
70
|
this.contentRows = content.length
|
|
59
71
|
const theme = this.view.theme()
|
|
60
72
|
const placeholder = this.getText() === '' ? this.view.placeholder() : undefined
|
|
73
|
+
const command = leadingSlashCommand(this.getText(), this.validCommands)
|
|
74
|
+
const commandLine = command ? content.findIndex(line => line.includes(command)) : -1
|
|
61
75
|
const prompt = content.map((line, index) => {
|
|
62
76
|
const cue = theme.fg('dim', index === 0 ? '❯ '.slice(0, this.gutterWidth) : ' '.repeat(this.gutterWidth))
|
|
63
77
|
if (index === 0 && placeholder) return cue + this.placeholderRow(placeholder, innerWidth, theme)
|
|
64
|
-
|
|
78
|
+
const renderedLine = index === commandLine && command ? line.replace(command, theme.fg('mdLink', command)) : line
|
|
79
|
+
return cue + renderedLine
|
|
65
80
|
})
|
|
66
81
|
const completions = rows.slice(bottom + 1).map((line) => ' '.repeat(this.gutterWidth) + line)
|
|
67
82
|
this.completionRows = completions.length
|
|
@@ -4,11 +4,10 @@ import { join } from 'node:path'
|
|
|
4
4
|
import { expect, it } from 'vitest'
|
|
5
5
|
import { terminalKeybindings } from './keybindings'
|
|
6
6
|
|
|
7
|
-
it('steps thinking effort on shift arrows
|
|
7
|
+
it('steps thinking effort on shift arrows', () => {
|
|
8
8
|
const manager = terminalKeybindings('/unused/isolated-account')
|
|
9
9
|
expect(manager.matches('\x1b[1;2A', 'app.thinking.increase')).toBe(true)
|
|
10
10
|
expect(manager.matches('\x1b[1;2B', 'app.thinking.decrease')).toBe(true)
|
|
11
|
-
expect(manager.matches('\x1b[Z', 'app.plan.toggle')).toBe(true)
|
|
12
11
|
expect(manager.matches('\x1b[Z', 'app.thinking.cycle')).toBe(false)
|
|
13
12
|
})
|
|
14
13
|
|
|
@@ -7,7 +7,6 @@ declare module '@earendil-works/pi-tui' {
|
|
|
7
7
|
interface Keybindings {
|
|
8
8
|
'app.thinking.increase': true
|
|
9
9
|
'app.thinking.decrease': true
|
|
10
|
-
'app.plan.toggle': true
|
|
11
10
|
}
|
|
12
11
|
}
|
|
13
12
|
|
|
@@ -15,7 +14,7 @@ declare module '@earendil-works/pi-tui' {
|
|
|
15
14
|
const actions: Record<string, string | string[]> = {
|
|
16
15
|
'app.interrupt': 'escape', 'app.clear': 'ctrl+c', 'app.exit': 'ctrl+d',
|
|
17
16
|
'app.suspend': process.platform === 'win32' ? [] : 'ctrl+z',
|
|
18
|
-
'app.thinking.cycle': [], 'app.thinking.save': 'ctrl+s',
|
|
17
|
+
'app.thinking.cycle': [], 'app.thinking.save': 'ctrl+s',
|
|
19
18
|
'app.thinking.increase': 'shift+up', 'app.thinking.decrease': 'shift+down',
|
|
20
19
|
'app.model.cycleForward': 'ctrl+p', 'app.model.cycleBackward': 'alt+p',
|
|
21
20
|
'app.model.select': 'ctrl+l', 'app.tools.expand': 'ctrl+o', 'app.thinking.toggle': 'ctrl+t',
|
|
@@ -1,12 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
// Read-only inspection, questions and recall. Everything else is withdrawn in plan mode: file changes, commands,
|
|
4
|
-
// memory writes, delegation and connected apps, since none of them can be judged read-only from their names.
|
|
5
|
-
const READ_ONLY_TOOLS = new Set([
|
|
6
|
-
'read', 'grep', 'find', 'ls', ASK_USER, 'ask_user_question',
|
|
7
|
-
'history_search', 'history_read', 'wiki_entrypoint', 'wiki_search', 'wiki_read', 'wiki_list', 'memory_facts'
|
|
8
|
-
])
|
|
9
|
-
|
|
10
|
-
export function planModeTools(normal: readonly string[]): string[] {
|
|
11
|
-
return normal.filter(name => READ_ONLY_TOOLS.has(name))
|
|
12
|
-
}
|
|
1
|
+
// The terminal's plan mode uses the session's read-only tool set.
|
|
2
|
+
export { planModeTools } from '../../sessions/planMode'
|
package/src/tools/types.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface ToolSpec {
|
|
|
20
20
|
description: string
|
|
21
21
|
/** Builds the TypeBox parameter schema. */
|
|
22
22
|
parameters: (type: typeof import('typebox').Type) => unknown
|
|
23
|
+
/** Only reads or asks; such a tool stays callable in plan mode. */
|
|
24
|
+
readOnly?: boolean
|
|
23
25
|
execute: (input: Record<string, unknown>) => Promise<string | ToolResult>
|
|
24
26
|
}
|
|
25
27
|
|