@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.
@@ -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 togglePlanMode(): void {
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 = !this.planMode
320
- this.notify(this.planMode ? 'Plan mode: only read-only tools are available. Shift+Tab returns to normal.' : 'Normal mode: all 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
- this.baseAutocomplete = skillCompletions(new CombinedAutocompleteProvider(this.commands.completions(), this.runtime.cwd), this.commands.skills)
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
- return cue + line
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 and gives shift+tab to plan mode', () => {
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', 'app.plan.toggle': 'shift+tab',
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
- import { ASK_USER } from '../../tools/askUser'
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'
@@ -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