mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-11 00:02:38 +02:00
commit
4709e6eb89
13 changed files with 578 additions and 14 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useCallback, useEffect, useState, useRef } from 'react'
|
import { useCallback, useEffect, useLayoutEffect, useState, useRef } from 'react'
|
||||||
import { workspace } from '@x/shared';
|
import { workspace } from '@x/shared';
|
||||||
import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js';
|
import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js';
|
||||||
import type { LanguageModelUsage, ToolUIPart } from 'ai';
|
import type { LanguageModelUsage, ToolUIPart } from 'ai';
|
||||||
|
|
@ -41,6 +41,7 @@ import { WebSearchResult } from '@/components/ai-elements/web-search-result';
|
||||||
import { AppActionCard } from '@/components/ai-elements/app-action-card';
|
import { AppActionCard } from '@/components/ai-elements/app-action-card';
|
||||||
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card';
|
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card';
|
||||||
import { PermissionRequest } from '@/components/ai-elements/permission-request';
|
import { PermissionRequest } from '@/components/ai-elements/permission-request';
|
||||||
|
import { TerminalOutput } from '@/components/terminal-output';
|
||||||
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request';
|
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request';
|
||||||
import { Suggestions } from '@/components/ai-elements/suggestions';
|
import { Suggestions } from '@/components/ai-elements/suggestions';
|
||||||
import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js';
|
import { ToolPermissionRequestEvent, AskHumanRequestEvent } from '@x/shared/src/runs.js';
|
||||||
|
|
@ -121,6 +122,31 @@ function SmoothStreamingMessage({ text, components }: { text: string; components
|
||||||
return <MessageResponse components={components}>{smoothText}</MessageResponse>
|
return <MessageResponse components={components}>{smoothText}</MessageResponse>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AutoScrollPre({ className, children }: { className?: string; children: React.ReactNode }) {
|
||||||
|
const ref = useRef<HTMLPreElement>(null)
|
||||||
|
const stickToBottom = useRef(true)
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = ref.current
|
||||||
|
if (el && stickToBottom.current) {
|
||||||
|
el.scrollTop = el.scrollHeight
|
||||||
|
}
|
||||||
|
}, [children])
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
const el = ref.current
|
||||||
|
if (!el) return
|
||||||
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24
|
||||||
|
stickToBottom.current = atBottom
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pre ref={ref} onScroll={handleScroll} className={className}>
|
||||||
|
{children}
|
||||||
|
</pre>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_SIDEBAR_WIDTH = 256
|
const DEFAULT_SIDEBAR_WIDTH = 256
|
||||||
const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g
|
const wikiLinkRegex = /\[\[([^[\]]+)\]\]/g
|
||||||
const graphPalette = [
|
const graphPalette = [
|
||||||
|
|
@ -2085,6 +2111,10 @@ function App() {
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (event.toolCallId && event.toolName !== 'executeCommand') {
|
||||||
|
setToolOpenForTab(activeChatTabIdRef.current, event.toolCallId, false)
|
||||||
|
}
|
||||||
|
|
||||||
// Handle app-navigation tool results — trigger UI side effects
|
// Handle app-navigation tool results — trigger UI side effects
|
||||||
if (event.toolName === 'app-navigation') {
|
if (event.toolName === 'app-navigation') {
|
||||||
const result = event.result as { success?: boolean; action?: string; [key: string]: unknown } | undefined
|
const result = event.result as { success?: boolean; action?: string; [key: string]: unknown } | undefined
|
||||||
|
|
@ -2096,6 +2126,23 @@ function App() {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'tool-output-stream': {
|
||||||
|
if (!isActiveRun) return
|
||||||
|
setConversation(prev => prev.map(item => {
|
||||||
|
if (
|
||||||
|
isToolCall(item)
|
||||||
|
&& item.id === event.toolCallId
|
||||||
|
) {
|
||||||
|
if (!item.streamingOutput) {
|
||||||
|
setToolOpenForTab(activeChatTabIdRef.current, item.id, true)
|
||||||
|
}
|
||||||
|
return { ...item, streamingOutput: (item.streamingOutput ?? '') + event.output }
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case 'tool-permission-request': {
|
case 'tool-permission-request': {
|
||||||
if (!isActiveRun) return
|
if (!isActiveRun) return
|
||||||
const key = event.toolCall.toolCallId
|
const key = event.toolCall.toolCallId
|
||||||
|
|
@ -4314,7 +4361,13 @@ function App() {
|
||||||
state={toToolState(item.status)}
|
state={toToolState(item.status)}
|
||||||
/>
|
/>
|
||||||
<ToolContent>
|
<ToolContent>
|
||||||
|
{item.streamingOutput ? (
|
||||||
|
<AutoScrollPre className="max-h-80 overflow-auto px-4 py-3 font-mono text-xs whitespace-pre-wrap text-foreground/90">
|
||||||
|
<TerminalOutput raw={item.streamingOutput} />
|
||||||
|
</AutoScrollPre>
|
||||||
|
) : (
|
||||||
<ToolTabbedContent input={input} output={output} errorText={errorText} />
|
<ToolTabbedContent input={input} output={output} errorText={errorText} />
|
||||||
|
)}
|
||||||
</ToolContent>
|
</ToolContent>
|
||||||
</Tool>
|
</Tool>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { Tool, ToolContent, ToolGroupComponent, ToolHeader, ToolTabbedContent }
|
||||||
import { WebSearchResult } from '@/components/ai-elements/web-search-result'
|
import { WebSearchResult } from '@/components/ai-elements/web-search-result'
|
||||||
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'
|
import { ComposioConnectCard } from '@/components/ai-elements/composio-connect-card'
|
||||||
import { PermissionRequest } from '@/components/ai-elements/permission-request'
|
import { PermissionRequest } from '@/components/ai-elements/permission-request'
|
||||||
|
import { TerminalOutput } from '@/components/terminal-output'
|
||||||
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
|
import { AskHumanRequest } from '@/components/ai-elements/ask-human-request'
|
||||||
import { Suggestions } from '@/components/ai-elements/suggestions'
|
import { Suggestions } from '@/components/ai-elements/suggestions'
|
||||||
import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input'
|
import { type PromptInputMessage, type FileMention } from '@/components/ai-elements/prompt-input'
|
||||||
|
|
@ -59,6 +60,31 @@ const streamdownComponents = { pre: MarkdownPreOverride }
|
||||||
// into <br> so typed line breaks are preserved without requiring blank lines.
|
// into <br> so typed line breaks are preserved without requiring blank lines.
|
||||||
const userMessageRemarkPlugins = [...Object.values(defaultRemarkPlugins), remarkBreaks]
|
const userMessageRemarkPlugins = [...Object.values(defaultRemarkPlugins), remarkBreaks]
|
||||||
|
|
||||||
|
function AutoScrollPre({ className, children }: { className?: string; children: React.ReactNode }) {
|
||||||
|
const ref = useRef<HTMLPreElement>(null)
|
||||||
|
const stickToBottom = useRef(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current
|
||||||
|
if (el && stickToBottom.current) {
|
||||||
|
el.scrollTop = el.scrollHeight
|
||||||
|
}
|
||||||
|
}, [children])
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
const el = ref.current
|
||||||
|
if (!el) return
|
||||||
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24
|
||||||
|
stickToBottom.current = atBottom
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pre ref={ref} onScroll={handleScroll} className={className}>
|
||||||
|
{children}
|
||||||
|
</pre>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Billing error helpers ─── */
|
/* ─── Billing error helpers ─── */
|
||||||
|
|
||||||
const BILLING_ERROR_PATTERNS = [
|
const BILLING_ERROR_PATTERNS = [
|
||||||
|
|
@ -452,7 +478,13 @@ export function ChatSidebar({
|
||||||
>
|
>
|
||||||
<ToolHeader title={toolTitle} type={`tool-${item.name}`} state={toToolState(item.status)} />
|
<ToolHeader title={toolTitle} type={`tool-${item.name}`} state={toToolState(item.status)} />
|
||||||
<ToolContent>
|
<ToolContent>
|
||||||
|
{item.streamingOutput ? (
|
||||||
|
<AutoScrollPre className="max-h-80 overflow-auto px-4 py-3 font-mono text-xs whitespace-pre-wrap text-foreground/90">
|
||||||
|
<TerminalOutput raw={item.streamingOutput} />
|
||||||
|
</AutoScrollPre>
|
||||||
|
) : (
|
||||||
<ToolTabbedContent input={input} output={output} errorText={errorText} />
|
<ToolTabbedContent input={input} output={output} errorText={errorText} />
|
||||||
|
)}
|
||||||
</ToolContent>
|
</ToolContent>
|
||||||
</Tool>
|
</Tool>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
24
apps/x/apps/renderer/src/components/terminal-output.tsx
Normal file
24
apps/x/apps/renderer/src/components/terminal-output.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import React, { useMemo } from 'react'
|
||||||
|
import { processTerminalOutput, spanStyleToCSS } from '../lib/terminal-output'
|
||||||
|
|
||||||
|
export function TerminalOutput({ raw }: { raw: string }) {
|
||||||
|
const lines = useMemo(() => processTerminalOutput(raw), [raw])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{lines.map((line, lineIdx) => (
|
||||||
|
<React.Fragment key={lineIdx}>
|
||||||
|
{lineIdx > 0 && '\n'}
|
||||||
|
{line.spans.map((span, spanIdx) => {
|
||||||
|
const css = spanStyleToCSS(span.style)
|
||||||
|
return css ? (
|
||||||
|
<span key={spanIdx} style={css}>{span.text}</span>
|
||||||
|
) : (
|
||||||
|
<React.Fragment key={spanIdx}>{span.text}</React.Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,7 @@ export interface ToolCall {
|
||||||
name: string
|
name: string
|
||||||
input: ToolUIPart['input']
|
input: ToolUIPart['input']
|
||||||
result?: ToolUIPart['output']
|
result?: ToolUIPart['output']
|
||||||
|
streamingOutput?: string
|
||||||
status: 'pending' | 'running' | 'completed' | 'error'
|
status: 'pending' | 'running' | 'completed' | 'error'
|
||||||
timestamp: number
|
timestamp: number
|
||||||
}
|
}
|
||||||
|
|
|
||||||
319
apps/x/apps/renderer/src/lib/terminal-output.ts
Normal file
319
apps/x/apps/renderer/src/lib/terminal-output.ts
Normal file
|
|
@ -0,0 +1,319 @@
|
||||||
|
/**
|
||||||
|
* Terminal output processor that handles ANSI escape sequences, carriage returns,
|
||||||
|
* and other terminal control characters to produce styled, terminal-like output.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface StyledSpan {
|
||||||
|
text: string
|
||||||
|
style: SpanStyle
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpanStyle {
|
||||||
|
bold?: boolean
|
||||||
|
dim?: boolean
|
||||||
|
italic?: boolean
|
||||||
|
underline?: boolean
|
||||||
|
strikethrough?: boolean
|
||||||
|
fg?: string
|
||||||
|
bg?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TerminalLine {
|
||||||
|
spans: StyledSpan[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANSI_COLORS_16: Record<number, string> = {
|
||||||
|
30: '#4e4e4e', 31: '#e06c75', 32: '#98c379', 33: '#e5c07b',
|
||||||
|
34: '#61afef', 35: '#c678dd', 36: '#56b6c2', 37: '#dcdfe4',
|
||||||
|
90: '#5c6370', 91: '#e06c75', 92: '#98c379', 93: '#e5c07b',
|
||||||
|
94: '#61afef', 95: '#c678dd', 96: '#56b6c2', 97: '#ffffff',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANSI_BG_COLORS_16: Record<number, string> = {
|
||||||
|
40: '#4e4e4e', 41: '#e06c75', 42: '#98c379', 43: '#e5c07b',
|
||||||
|
44: '#61afef', 45: '#c678dd', 46: '#56b6c2', 47: '#dcdfe4',
|
||||||
|
100: '#5c6370', 101: '#e06c75', 102: '#98c379', 103: '#e5c07b',
|
||||||
|
104: '#61afef', 105: '#c678dd', 106: '#56b6c2', 107: '#ffffff',
|
||||||
|
}
|
||||||
|
|
||||||
|
function color256(n: number): string {
|
||||||
|
if (n < 8) return ANSI_COLORS_16[30 + n] ?? '#dcdfe4'
|
||||||
|
if (n < 16) return ANSI_COLORS_16[90 + (n - 8)] ?? '#dcdfe4'
|
||||||
|
if (n < 232) {
|
||||||
|
const idx = n - 16
|
||||||
|
const r = Math.floor(idx / 36)
|
||||||
|
const g = Math.floor((idx % 36) / 6)
|
||||||
|
const b = idx % 6
|
||||||
|
const toHex = (v: number) => (v === 0 ? 0 : 55 + v * 40).toString(16).padStart(2, '0')
|
||||||
|
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
||||||
|
}
|
||||||
|
const level = 8 + (n - 232) * 10
|
||||||
|
const hex = level.toString(16).padStart(2, '0')
|
||||||
|
return `#${hex}${hex}${hex}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSGR(params: number[], style: SpanStyle): SpanStyle {
|
||||||
|
const s = { ...style }
|
||||||
|
let i = 0
|
||||||
|
while (i < params.length) {
|
||||||
|
const p = params[i]
|
||||||
|
if (p === 0) {
|
||||||
|
delete s.bold
|
||||||
|
delete s.dim
|
||||||
|
delete s.italic
|
||||||
|
delete s.underline
|
||||||
|
delete s.strikethrough
|
||||||
|
delete s.fg
|
||||||
|
delete s.bg
|
||||||
|
} else if (p === 1) s.bold = true
|
||||||
|
else if (p === 2) s.dim = true
|
||||||
|
else if (p === 3) s.italic = true
|
||||||
|
else if (p === 4) s.underline = true
|
||||||
|
else if (p === 9) s.strikethrough = true
|
||||||
|
else if (p === 22) {
|
||||||
|
delete s.bold
|
||||||
|
delete s.dim
|
||||||
|
} else if (p === 23) delete s.italic
|
||||||
|
else if (p === 24) delete s.underline
|
||||||
|
else if (p === 29) delete s.strikethrough
|
||||||
|
else if (p >= 30 && p <= 37) s.fg = ANSI_COLORS_16[p]
|
||||||
|
else if (p === 38) {
|
||||||
|
if (params[i + 1] === 5 && params[i + 2] !== undefined) {
|
||||||
|
s.fg = color256(params[i + 2])
|
||||||
|
i += 2
|
||||||
|
} else if (params[i + 1] === 2 && params[i + 4] !== undefined) {
|
||||||
|
const r = params[i + 2]
|
||||||
|
const g = params[i + 3]
|
||||||
|
const b = params[i + 4]
|
||||||
|
s.fg = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
|
||||||
|
i += 4
|
||||||
|
}
|
||||||
|
} else if (p === 39) delete s.fg
|
||||||
|
else if (p >= 40 && p <= 47) s.bg = ANSI_BG_COLORS_16[p]
|
||||||
|
else if (p === 48) {
|
||||||
|
if (params[i + 1] === 5 && params[i + 2] !== undefined) {
|
||||||
|
s.bg = color256(params[i + 2])
|
||||||
|
i += 2
|
||||||
|
} else if (params[i + 1] === 2 && params[i + 4] !== undefined) {
|
||||||
|
const r = params[i + 2]
|
||||||
|
const g = params[i + 3]
|
||||||
|
const b = params[i + 4]
|
||||||
|
s.bg = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
|
||||||
|
i += 4
|
||||||
|
}
|
||||||
|
} else if (p === 49) delete s.bg
|
||||||
|
else if (p >= 90 && p <= 97) s.fg = ANSI_COLORS_16[p]
|
||||||
|
else if (p >= 100 && p <= 107) s.bg = ANSI_BG_COLORS_16[p]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
export function processTerminalOutput(raw: string): TerminalLine[] {
|
||||||
|
type Cell = { char: string; style: SpanStyle }
|
||||||
|
const lines: Cell[][] = [[]]
|
||||||
|
let cursorRow = 0
|
||||||
|
let cursorCol = 0
|
||||||
|
let currentStyle: SpanStyle = {}
|
||||||
|
|
||||||
|
function ensureRow(row: number) {
|
||||||
|
while (lines.length <= row) lines.push([])
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureCol(row: number, col: number) {
|
||||||
|
ensureRow(row)
|
||||||
|
const line = lines[row]
|
||||||
|
while (line.length <= col) line.push({ char: ' ', style: {} })
|
||||||
|
}
|
||||||
|
|
||||||
|
let i = 0
|
||||||
|
while (i < raw.length) {
|
||||||
|
const ch = raw[i]
|
||||||
|
|
||||||
|
if (ch === '\x1b' && i + 1 < raw.length) {
|
||||||
|
const next = raw[i + 1]
|
||||||
|
|
||||||
|
if (next === '[') {
|
||||||
|
i += 2
|
||||||
|
let paramStr = ''
|
||||||
|
while (i < raw.length && raw[i] >= '\x20' && raw[i] <= '\x3f') {
|
||||||
|
paramStr += raw[i]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
const finalByte = i < raw.length ? raw[i] : ''
|
||||||
|
i++
|
||||||
|
|
||||||
|
const params = paramStr.length > 0
|
||||||
|
? paramStr.split(';').map(s => parseInt(s, 10) || 0)
|
||||||
|
: [0]
|
||||||
|
|
||||||
|
switch (finalByte) {
|
||||||
|
case 'm':
|
||||||
|
currentStyle = parseSGR(params, currentStyle)
|
||||||
|
break
|
||||||
|
case 'A':
|
||||||
|
cursorRow = Math.max(0, cursorRow - (params[0] || 1))
|
||||||
|
break
|
||||||
|
case 'B':
|
||||||
|
cursorRow += (params[0] || 1)
|
||||||
|
ensureRow(cursorRow)
|
||||||
|
break
|
||||||
|
case 'C':
|
||||||
|
cursorCol += (params[0] || 1)
|
||||||
|
break
|
||||||
|
case 'D':
|
||||||
|
cursorCol = Math.max(0, cursorCol - (params[0] || 1))
|
||||||
|
break
|
||||||
|
case 'G':
|
||||||
|
cursorCol = Math.max(0, (params[0] || 1) - 1)
|
||||||
|
break
|
||||||
|
case 'H':
|
||||||
|
case 'f':
|
||||||
|
cursorRow = Math.max(0, (params[0] || 1) - 1)
|
||||||
|
cursorCol = Math.max(0, (params[1] || 1) - 1)
|
||||||
|
ensureRow(cursorRow)
|
||||||
|
break
|
||||||
|
case 'J': {
|
||||||
|
const mode = params[0] || 0
|
||||||
|
if (mode === 2 || mode === 3) {
|
||||||
|
lines.length = 0
|
||||||
|
lines.push([])
|
||||||
|
cursorRow = 0
|
||||||
|
cursorCol = 0
|
||||||
|
} else if (mode === 0) {
|
||||||
|
ensureRow(cursorRow)
|
||||||
|
lines[cursorRow].length = cursorCol
|
||||||
|
for (let r = cursorRow + 1; r < lines.length; r++) lines[r] = []
|
||||||
|
} else if (mode === 1) {
|
||||||
|
for (let r = 0; r < cursorRow; r++) lines[r] = []
|
||||||
|
ensureCol(cursorRow, cursorCol)
|
||||||
|
for (let c = 0; c <= cursorCol; c++) lines[cursorRow][c] = { char: ' ', style: {} }
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'K': {
|
||||||
|
const mode = params[0] || 0
|
||||||
|
ensureRow(cursorRow)
|
||||||
|
const line = lines[cursorRow]
|
||||||
|
if (mode === 0) {
|
||||||
|
line.length = cursorCol
|
||||||
|
} else if (mode === 1) {
|
||||||
|
ensureCol(cursorRow, cursorCol)
|
||||||
|
for (let c = 0; c <= cursorCol; c++) line[c] = { char: ' ', style: {} }
|
||||||
|
} else if (mode === 2) {
|
||||||
|
lines[cursorRow] = []
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next === ']') {
|
||||||
|
i += 2
|
||||||
|
while (i < raw.length && raw[i] !== '\x07' && !(raw[i] === '\x1b' && raw[i + 1] === '\\')) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if (i < raw.length && raw[i] === '\x07') i++
|
||||||
|
else if (i < raw.length) i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === '\r') {
|
||||||
|
cursorCol = 0
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === '\n') {
|
||||||
|
cursorRow++
|
||||||
|
cursorCol = 0
|
||||||
|
ensureRow(cursorRow)
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === '\b') {
|
||||||
|
cursorCol = Math.max(0, cursorCol - 1)
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === '\t') {
|
||||||
|
const nextTabStop = (Math.floor(cursorCol / 8) + 1) * 8
|
||||||
|
while (cursorCol < nextTabStop) {
|
||||||
|
ensureCol(cursorRow, cursorCol)
|
||||||
|
lines[cursorRow][cursorCol] = { char: ' ', style: { ...currentStyle } }
|
||||||
|
cursorCol++
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch.charCodeAt(0) < 32) {
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureCol(cursorRow, cursorCol)
|
||||||
|
lines[cursorRow][cursorCol] = { char: ch, style: { ...currentStyle } }
|
||||||
|
cursorCol++
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.map(cells => {
|
||||||
|
const spans: StyledSpan[] = []
|
||||||
|
if (cells.length === 0) return { spans: [{ text: '', style: {} }] }
|
||||||
|
|
||||||
|
let end = cells.length
|
||||||
|
while (end > 0 && cells[end - 1].char === ' ' && Object.keys(cells[end - 1].style).length === 0) {
|
||||||
|
end--
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentSpan: StyledSpan | null = null
|
||||||
|
for (let c = 0; c < end; c++) {
|
||||||
|
const cell = cells[c]
|
||||||
|
const sameStyle = currentSpan && styleEquals(currentSpan.style, cell.style)
|
||||||
|
if (sameStyle && currentSpan) {
|
||||||
|
currentSpan.text += cell.char
|
||||||
|
} else {
|
||||||
|
if (currentSpan) spans.push(currentSpan)
|
||||||
|
currentSpan = { text: cell.char, style: { ...cell.style } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentSpan) spans.push(currentSpan)
|
||||||
|
if (spans.length === 0) spans.push({ text: '', style: {} })
|
||||||
|
return { spans }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function styleEquals(a: SpanStyle, b: SpanStyle): boolean {
|
||||||
|
return a.bold === b.bold
|
||||||
|
&& a.dim === b.dim
|
||||||
|
&& a.italic === b.italic
|
||||||
|
&& a.underline === b.underline
|
||||||
|
&& a.strikethrough === b.strikethrough
|
||||||
|
&& a.fg === b.fg
|
||||||
|
&& a.bg === b.bg
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spanStyleToCSS(style: SpanStyle): React.CSSProperties | undefined {
|
||||||
|
if (Object.keys(style).length === 0) return undefined
|
||||||
|
const css: React.CSSProperties = {}
|
||||||
|
if (style.fg) css.color = style.fg
|
||||||
|
if (style.bg) css.backgroundColor = style.bg
|
||||||
|
if (style.bold) css.fontWeight = 'bold'
|
||||||
|
if (style.dim) css.opacity = 0.6
|
||||||
|
if (style.italic) css.fontStyle = 'italic'
|
||||||
|
if (style.underline) css.textDecoration = 'underline'
|
||||||
|
if (style.strikethrough) {
|
||||||
|
css.textDecoration = css.textDecoration ? `${css.textDecoration} line-through` : 'line-through'
|
||||||
|
}
|
||||||
|
return Object.keys(css).length > 0 ? css : undefined
|
||||||
|
}
|
||||||
|
|
@ -176,6 +176,7 @@ export class AgentRuntime implements IAgentRuntime {
|
||||||
modelConfigRepo: this.modelConfigRepo,
|
modelConfigRepo: this.modelConfigRepo,
|
||||||
signal,
|
signal,
|
||||||
abortRegistry: this.abortRegistry,
|
abortRegistry: this.abortRegistry,
|
||||||
|
bus: this.bus,
|
||||||
})) {
|
})) {
|
||||||
eventCount++;
|
eventCount++;
|
||||||
if (event.type !== "llm-stream-event") {
|
if (event.type !== "llm-stream-event") {
|
||||||
|
|
@ -868,6 +869,7 @@ export async function* streamAgent({
|
||||||
modelConfigRepo,
|
modelConfigRepo,
|
||||||
signal,
|
signal,
|
||||||
abortRegistry,
|
abortRegistry,
|
||||||
|
bus,
|
||||||
}: {
|
}: {
|
||||||
state: AgentState,
|
state: AgentState,
|
||||||
idGenerator: IMonotonicallyIncreasingIdGenerator;
|
idGenerator: IMonotonicallyIncreasingIdGenerator;
|
||||||
|
|
@ -876,6 +878,7 @@ export async function* streamAgent({
|
||||||
modelConfigRepo: IModelConfigRepo;
|
modelConfigRepo: IModelConfigRepo;
|
||||||
signal: AbortSignal;
|
signal: AbortSignal;
|
||||||
abortRegistry: IAbortRegistry;
|
abortRegistry: IAbortRegistry;
|
||||||
|
bus: IBus;
|
||||||
}): AsyncGenerator<z.infer<typeof RunEvent>, void, unknown> {
|
}): AsyncGenerator<z.infer<typeof RunEvent>, void, unknown> {
|
||||||
const logger = new PrefixLogger(`run-${runId}-${state.agentName}`);
|
const logger = new PrefixLogger(`run-${runId}-${state.agentName}`);
|
||||||
|
|
||||||
|
|
@ -989,6 +992,7 @@ export async function* streamAgent({
|
||||||
modelConfigRepo,
|
modelConfigRepo,
|
||||||
signal,
|
signal,
|
||||||
abortRegistry,
|
abortRegistry,
|
||||||
|
bus,
|
||||||
})) {
|
})) {
|
||||||
yield* processEvent({
|
yield* processEvent({
|
||||||
...event,
|
...event,
|
||||||
|
|
@ -999,7 +1003,13 @@ export async function* streamAgent({
|
||||||
result = subflowState.finalResponse();
|
result = subflowState.finalResponse();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
result = await execTool(agent.tools![toolCall.toolName], toolCall.arguments, { runId, signal, abortRegistry });
|
result = await execTool(agent.tools![toolCall.toolName], toolCall.arguments, {
|
||||||
|
runId,
|
||||||
|
toolCallId,
|
||||||
|
signal,
|
||||||
|
abortRegistry,
|
||||||
|
publish: (event) => bus.publish(event),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error instanceof Error && error.name === "AbortError") || signal.aborted) {
|
if ((error instanceof Error && error.name === "AbortError") || signal.aborted) {
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,8 @@ ${thirdPartyBlock}**Meeting Prep:** When users ask you to prepare for a meeting,
|
||||||
|
|
||||||
**Document Collaboration:** When users ask you to work on a document, collaborate on writing, create a new document, edit/refine existing notes, or say things like "let's work on [X]", "help me write [X]", "create a doc for [X]", or "let's draft [X]", you MUST load the \`doc-collab\` skill first. This is required for any document creation or editing task. The skill provides structured guidance for creating, editing, and refining documents in the knowledge base.
|
**Document Collaboration:** When users ask you to work on a document, collaborate on writing, create a new document, edit/refine existing notes, or say things like "let's work on [X]", "help me write [X]", "create a doc for [X]", or "let's draft [X]", you MUST load the \`doc-collab\` skill first. This is required for any document creation or editing task. The skill provides structured guidance for creating, editing, and refining documents in the knowledge base.
|
||||||
|
|
||||||
|
**Code with Agents:** When users ask you to write code, build a project, create a script, fix a bug, or do any software development task, load the \`code-with-agents\` skill first. It provides guidance for delegating coding work to Claude Code or Codex via acpx.
|
||||||
|
|
||||||
**App Control:** When users ask you to open notes, show the bases or graph view, filter or search notes, or manage saved views, load the \`app-navigation\` skill first. It provides structured guidance for navigating the app UI and controlling the knowledge base view.
|
**App Control:** When users ask you to open notes, show the bases or graph view, filter or search notes, or manage saved views, load the \`app-navigation\` skill first. It provides structured guidance for navigating the app UI and controlling the knowledge base view.
|
||||||
|
|
||||||
**Tracks (Auto-Updating Note Blocks):** When users ask you to **track**, **monitor**, **watch**, or **keep an eye on** something in a note — or say things like "every morning tell me X", "show the current Y in this note", "pin live updates of Z here" — load the \`tracks\` skill first. Also load it when a user presses Cmd+K with a note open and requests auto-refreshing content at the cursor. Track blocks are YAML-fenced scheduled blocks whose output is rewritten on each run — useful for weather, news, prices, status pages, and personal dashboards.
|
**Tracks (Auto-Updating Note Blocks):** When users ask you to **track**, **monitor**, **watch**, or **keep an eye on** something in a note — or say things like "every morning tell me X", "show the current Y in this note", "pin live updates of Z here" — load the \`tracks\` skill first. Also load it when a user presses Cmd+K with a note open and requests auto-refreshing content at the cursor. Track blocks are YAML-fenced scheduled blocks whose output is rewritten on each run — useful for weather, news, prices, status pages, and personal dashboards.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
export const skill = String.raw`
|
||||||
|
# Code with Agents Skill
|
||||||
|
|
||||||
|
Use this skill when the user asks you to write code, build a project, create scripts, fix bugs, or do any software development task that should be delegated to a coding agent (Claude Code or Codex).
|
||||||
|
|
||||||
|
## Important: delegate ALL coding work
|
||||||
|
|
||||||
|
Once the user has chosen to use Claude Code or Codex, you MUST delegate ALL code-related tasks to the coding agent. This includes:
|
||||||
|
- Writing, editing, or refactoring code
|
||||||
|
- Reading, summarizing, or explaining code
|
||||||
|
- Debugging and fixing bugs
|
||||||
|
- Running tests or build commands
|
||||||
|
- Exploring project structure
|
||||||
|
- Any other task that involves interacting with a codebase
|
||||||
|
|
||||||
|
Do NOT attempt to do any of these yourself — no reading files, no running commands, no writing code. You are the coordinator; the coding agent does the work. Your job is to translate the user's request into a clear prompt and pass it to the agent.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
The user must have one of the following installed on their machine:
|
||||||
|
- **Claude Code** — https://claude.ai/code
|
||||||
|
- **Codex** — https://codex.openai.com
|
||||||
|
|
||||||
|
These are external tools that you cannot install for the user.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### Step 1: Gather requirements
|
||||||
|
|
||||||
|
Before running anything, confirm the following with the user:
|
||||||
|
|
||||||
|
1. **Working directory** — Ask which folder the code should be written in, unless the user has already specified it. Example: "Which folder should I work in?"
|
||||||
|
2. **Agent choice** — Ask whether to use **Claude Code** or **Codex**. Mention that the chosen agent must already be installed on their machine.
|
||||||
|
|
||||||
|
### Step 2: Confirm execution plan
|
||||||
|
|
||||||
|
Once you know the folder and agent, tell the user:
|
||||||
|
|
||||||
|
> I'll use [Claude Code / Codex] to [description of the task] in \`[folder]\`. Permission requests from the coding agent itself (file writes, command execution, etc.) will be automatically approved once it starts. Wait for the user's confirmation before you execute anything.
|
||||||
|
|
||||||
|
### Step 3: Execute with acpx
|
||||||
|
|
||||||
|
Use the \`executeCommand\` tool to run the coding agent via acpx. The command format is:
|
||||||
|
|
||||||
|
**For Claude Code:**
|
||||||
|
` + "`" + `
|
||||||
|
npx acpx@latest --approve-all --cwd <folder> claude exec "<prompt>"
|
||||||
|
` + "`" + `
|
||||||
|
|
||||||
|
**For Codex:**
|
||||||
|
` + "`" + `
|
||||||
|
npx acpx@latest --approve-all --cwd <folder> codex exec "<prompt>"
|
||||||
|
` + "`" + `
|
||||||
|
|
||||||
|
### Critical: flag order
|
||||||
|
|
||||||
|
The \`--approve-all\` and \`--cwd\` flags are global flags and MUST come before the agent name (\`claude\` or \`codex\`). This is the correct order:
|
||||||
|
|
||||||
|
` + "`" + `
|
||||||
|
npx acpx@latest [global flags] <agent> exec "<prompt>"
|
||||||
|
` + "`" + `
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
` + "`" + `
|
||||||
|
npx acpx@latest --approve-all --cwd ~/projects/myapp claude exec "fix the bug"
|
||||||
|
` + "`" + `
|
||||||
|
|
||||||
|
**Wrong (will fail):**
|
||||||
|
` + "`" + `
|
||||||
|
npx acpx@latest claude --approve-all exec "fix the bug"
|
||||||
|
` + "`" + `
|
||||||
|
|
||||||
|
### Writing good prompts
|
||||||
|
|
||||||
|
When constructing the prompt for the coding agent:
|
||||||
|
- Be specific and detailed about what to build or fix
|
||||||
|
- Include file names, function signatures, and expected behavior
|
||||||
|
- Mention any constraints (language, framework, style)
|
||||||
|
- If the user gave you a short request, expand it into a clear, actionable prompt for the agent
|
||||||
|
|
||||||
|
### Step 4: Report results
|
||||||
|
|
||||||
|
After the command finishes, look for the summary that the coding agent produced at the end of its output and pass that along to the user as-is. Do not rewrite or add to it. Only add your own explanation if the command failed or the exit code is non-zero.
|
||||||
|
|
||||||
|
Do NOT use file reference blocks (e.g. \`\`\`file:path/to/file\`\`\`) when mentioning code files — they may not open correctly. Just refer to file paths as plain text.
|
||||||
|
|
||||||
|
- If the exit code is 5, it means permissions were denied — this should not happen with \`--approve-all\`, but if it does, let the user know
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default skill;
|
||||||
|
|
@ -11,6 +11,7 @@ import createPresentationsSkill from "./create-presentations/skill.js";
|
||||||
|
|
||||||
import appNavigationSkill from "./app-navigation/skill.js";
|
import appNavigationSkill from "./app-navigation/skill.js";
|
||||||
import browserControlSkill from "./browser-control/skill.js";
|
import browserControlSkill from "./browser-control/skill.js";
|
||||||
|
import codeWithAgentsSkill from "./code-with-agents/skill.js";
|
||||||
import composioIntegrationSkill from "./composio-integration/skill.js";
|
import composioIntegrationSkill from "./composio-integration/skill.js";
|
||||||
import tracksSkill from "./tracks/skill.js";
|
import tracksSkill from "./tracks/skill.js";
|
||||||
import notifyUserSkill from "./notify-user/skill.js";
|
import notifyUserSkill from "./notify-user/skill.js";
|
||||||
|
|
@ -94,6 +95,12 @@ const definitions: SkillDefinition[] = [
|
||||||
summary: "Navigate the app UI - open notes, switch views, filter/search the knowledge base, and manage saved views.",
|
summary: "Navigate the app UI - open notes, switch views, filter/search the knowledge base, and manage saved views.",
|
||||||
content: appNavigationSkill,
|
content: appNavigationSkill,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "code-with-agents",
|
||||||
|
title: "Code with Agents",
|
||||||
|
summary: "Write code, build projects, create scripts, or fix bugs by delegating to Claude Code or Codex via acpx.",
|
||||||
|
content: codeWithAgentsSkill,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "tracks",
|
id: "tracks",
|
||||||
title: "Tracks",
|
title: "Tracks",
|
||||||
|
|
|
||||||
|
|
@ -969,6 +969,16 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
||||||
const { promise, process: proc } = executeCommandAbortable(command, {
|
const { promise, process: proc } = executeCommandAbortable(command, {
|
||||||
cwd: workingDir,
|
cwd: workingDir,
|
||||||
signal: ctx.signal,
|
signal: ctx.signal,
|
||||||
|
onData: (chunk: string) => {
|
||||||
|
ctx.publish({
|
||||||
|
runId: ctx.runId,
|
||||||
|
type: "tool-output-stream",
|
||||||
|
toolCallId: ctx.toolCallId,
|
||||||
|
toolName: "executeCommand",
|
||||||
|
output: chunk,
|
||||||
|
subflow: [],
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register process with abort registry for force-kill
|
// Register process with abort registry for force-kill
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,7 @@ export function executeCommandAbortable(
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
maxBuffer?: number;
|
maxBuffer?: number;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
onData?: (chunk: string) => void;
|
||||||
}
|
}
|
||||||
): { promise: Promise<AbortableCommandResult>; process: ChildProcess } {
|
): { promise: Promise<AbortableCommandResult>; process: ChildProcess } {
|
||||||
// Check if already aborted before spawning
|
// Check if already aborted before spawning
|
||||||
|
|
@ -177,16 +178,20 @@ export function executeCommandAbortable(
|
||||||
|
|
||||||
// Collect output
|
// Collect output
|
||||||
proc.stdout?.on('data', (chunk: Buffer) => {
|
proc.stdout?.on('data', (chunk: Buffer) => {
|
||||||
|
const text = chunk.toString();
|
||||||
const maxBuffer = options?.maxBuffer || 1024 * 1024;
|
const maxBuffer = options?.maxBuffer || 1024 * 1024;
|
||||||
if (stdout.length < maxBuffer) {
|
if (stdout.length < maxBuffer) {
|
||||||
stdout += chunk.toString();
|
stdout += text;
|
||||||
}
|
}
|
||||||
|
options?.onData?.(text);
|
||||||
});
|
});
|
||||||
proc.stderr?.on('data', (chunk: Buffer) => {
|
proc.stderr?.on('data', (chunk: Buffer) => {
|
||||||
|
const text = chunk.toString();
|
||||||
const maxBuffer = options?.maxBuffer || 1024 * 1024;
|
const maxBuffer = options?.maxBuffer || 1024 * 1024;
|
||||||
if (stderr.length < maxBuffer) {
|
if (stderr.length < maxBuffer) {
|
||||||
stderr += chunk.toString();
|
stderr += text;
|
||||||
}
|
}
|
||||||
|
options?.onData?.(text);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Abort handler
|
// Abort handler
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { ToolAttachment } from "@x/shared/dist/agent.js";
|
import { ToolAttachment } from "@x/shared/dist/agent.js";
|
||||||
|
import { RunEvent } from "@x/shared/dist/runs.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { BuiltinTools } from "./builtin-tools.js";
|
import { BuiltinTools } from "./builtin-tools.js";
|
||||||
import { executeTool } from "../../mcp/mcp.js";
|
import { executeTool } from "../../mcp/mcp.js";
|
||||||
|
|
@ -9,8 +10,10 @@ import { IAbortRegistry } from "../../runs/abort-registry.js";
|
||||||
*/
|
*/
|
||||||
export interface ToolContext {
|
export interface ToolContext {
|
||||||
runId: string;
|
runId: string;
|
||||||
|
toolCallId: string;
|
||||||
signal: AbortSignal;
|
signal: AbortSignal;
|
||||||
abortRegistry: IAbortRegistry;
|
abortRegistry: IAbortRegistry;
|
||||||
|
publish: (event: z.infer<typeof RunEvent>) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function execMcpTool(agentTool: z.infer<typeof ToolAttachment> & { type: "mcp" }, input: Record<string, unknown>): Promise<unknown> {
|
async function execMcpTool(agentTool: z.infer<typeof ToolAttachment> & { type: "mcp" }, input: Record<string, unknown>): Promise<unknown> {
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,13 @@ export const ToolResultEvent = BaseRunEvent.extend({
|
||||||
result: z.any(),
|
result: z.any(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const ToolOutputStreamEvent = BaseRunEvent.extend({
|
||||||
|
type: z.literal("tool-output-stream"),
|
||||||
|
toolCallId: z.string(),
|
||||||
|
toolName: z.string(),
|
||||||
|
output: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export const AskHumanRequestEvent = BaseRunEvent.extend({
|
export const AskHumanRequestEvent = BaseRunEvent.extend({
|
||||||
type: z.literal("ask-human-request"),
|
type: z.literal("ask-human-request"),
|
||||||
toolCallId: z.string(),
|
toolCallId: z.string(),
|
||||||
|
|
@ -106,6 +113,7 @@ export const RunEvent = z.union([
|
||||||
MessageEvent,
|
MessageEvent,
|
||||||
ToolInvocationEvent,
|
ToolInvocationEvent,
|
||||||
ToolResultEvent,
|
ToolResultEvent,
|
||||||
|
ToolOutputStreamEvent,
|
||||||
AskHumanRequestEvent,
|
AskHumanRequestEvent,
|
||||||
AskHumanResponseEvent,
|
AskHumanResponseEvent,
|
||||||
ToolPermissionRequestEvent,
|
ToolPermissionRequestEvent,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue