Skip to content

Commit b4374a6

Browse files
authored
Merge pull request #18 from yugo-ibuki/claude/fix-flick-logging-fqAZe
Handle ANSI escape sequences and FLICK mode in tmux pane capture
2 parents b1d6a2f + a89d80c commit b4374a6

11 files changed

Lines changed: 524 additions & 67 deletions

File tree

src/main/index.ts

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ import {
1515
createSession,
1616
killPane,
1717
findShellPane,
18-
ensureShellPane
18+
ensureShellPane,
19+
getConversationLog
1920
} from './tmux'
21+
import type { ChatMessage } from './tmux'
2022

2123
interface SkillEntry {
2224
name: string
@@ -58,23 +60,36 @@ async function listSkillsFromDir(baseDir: string): Promise<SkillEntry[]> {
5860
}
5961
}
6062

61-
// Streaming state: polls capture-pane and pushes new content to renderer
63+
// Streaming state: polls capture-pane or JSONL and pushes content to renderer
6264
let streamTarget: string | null = null
6365
let streamTimer: ReturnType<typeof setInterval> | null = null
66+
let streamMode: 'raw' | 'chat' = 'raw'
6467
let lastStreamContent = ''
68+
let lastChatJson = ''
6569

66-
function startStream(win: BrowserWindow, target: string): void {
70+
function startStream(win: BrowserWindow, target: string, mode: 'raw' | 'chat'): void {
6771
stopStream()
6872
streamTarget = target
73+
streamMode = mode
6974
lastStreamContent = ''
75+
lastChatJson = ''
7076

7177
const tick = async (): Promise<void> => {
7278
if (!streamTarget) return
7379
try {
74-
const content = await capturePane(streamTarget)
75-
if (content !== lastStreamContent) {
76-
lastStreamContent = content
77-
win.webContents.send('tmux:stream-data', content)
80+
if (streamMode === 'chat') {
81+
const messages: ChatMessage[] = await getConversationLog(streamTarget)
82+
const json = JSON.stringify(messages)
83+
if (json !== lastChatJson) {
84+
lastChatJson = json
85+
win.webContents.send('tmux:chat-data', messages)
86+
}
87+
} else {
88+
const content = await capturePane(streamTarget)
89+
if (content !== lastStreamContent) {
90+
lastStreamContent = content
91+
win.webContents.send('tmux:stream-data', content)
92+
}
7893
}
7994
} catch {
8095
// pane may have closed
@@ -91,7 +106,9 @@ function stopStream(): void {
91106
streamTimer = null
92107
}
93108
streamTarget = null
109+
streamMode = 'raw'
94110
lastStreamContent = ''
111+
lastChatJson = ''
95112
}
96113

97114
let mainWindow: BrowserWindow | null = null
@@ -171,12 +188,24 @@ app.whenReady().then(() => {
171188
return capturePane(target)
172189
})
173190

174-
ipcMain.handle('tmux:start-stream', async (_event, target: string) => {
175-
const win = mainWindow
176-
if (win) startStream(win, target)
177-
return true
191+
ipcMain.handle('tmux:conversation-log', async (_event, target: string) => {
192+
return getConversationLog(target)
178193
})
179194

195+
ipcMain.handle(
196+
'tmux:start-stream',
197+
async (_event, arg: string | { target: string; mode: string }) => {
198+
const win = mainWindow
199+
if (!win) return true
200+
if (typeof arg === 'string') {
201+
startStream(win, arg, 'raw')
202+
} else {
203+
startStream(win, arg.target, (arg.mode as 'raw' | 'chat') ?? 'raw')
204+
}
205+
return true
206+
}
207+
)
208+
180209
ipcMain.handle('tmux:stop-stream', async () => {
181210
stopStream()
182211
return true

src/main/tmux.test.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ describe('detectStatusClaude', () => {
208208
})
209209

210210
describe('trimCliFooter', () => {
211-
it('passes content through unchanged', () => {
211+
it('strips CLI footer lines (separator, session info, prompt cursor)', () => {
212212
const content = [
213213
'⏺ Some response text',
214214
'',
@@ -220,6 +220,24 @@ describe('trimCliFooter', () => {
220220
].join('\n')
221221

222222
const result = trimCliFooter(content)
223-
expect(result).toBe(content)
223+
expect(result).toBe('⏺ Some response text')
224+
})
225+
226+
it('preserves content when no footer is present', () => {
227+
const content = '⏺ Some response text\nMore text here'
228+
expect(trimCliFooter(content)).toBe(content)
229+
})
230+
231+
it('strips FLICK mode footer patterns (token counts, cost)', () => {
232+
const content = [
233+
'⏺ Response',
234+
'',
235+
'─────────────────────────────────────',
236+
' Session abc | Model opus',
237+
'500 tokens',
238+
''
239+
].join('\n')
240+
241+
expect(trimCliFooter(content)).toBe('⏺ Response')
224242
})
225243
})

src/main/tmux.ts

Lines changed: 228 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { execFile } from 'child_process'
22
import { existsSync } from 'fs'
3+
import { readFile, readdir } from 'fs/promises'
4+
import { homedir } from 'os'
5+
import { join } from 'path'
36

47
export type PaneStatus = 'idle' | 'busy' | 'waiting'
58

@@ -18,6 +21,185 @@ export interface TmuxPane {
1821
prompt: string
1922
}
2023

24+
// Strip ANSI escape sequences from captured pane content.
25+
// capture-pane -p normally strips them, but FLICK (alternate screen) mode
26+
// can leave CSI / OSC remnants in certain tmux versions.
27+
const ESC = String.fromCharCode(0x1b)
28+
const BEL = String.fromCharCode(0x07)
29+
const RE_CSI = new RegExp(ESC + '\\[[0-9;]*[A-Za-z]', 'g')
30+
const RE_OSC = new RegExp(ESC + '\\][^' + BEL + ESC + ']*(?:' + BEL + '|' + ESC + '\\\\)', 'g')
31+
const RE_CHARSET = new RegExp(ESC + '[()][AB012]', 'g')
32+
const RE_MODE = new RegExp(ESC + '[>=]', 'g')
33+
34+
function stripAnsi(text: string): string {
35+
return text.replace(RE_CSI, '').replace(RE_OSC, '').replace(RE_CHARSET, '').replace(RE_MODE, '')
36+
}
37+
38+
// Detect if a pane is currently showing the alternate screen buffer
39+
// (i.e. Claude Code is running in FLICK / NO_FLICKER mode).
40+
async function isAlternateScreen(target: string): Promise<boolean> {
41+
try {
42+
const result = await run(['display-message', '-t', target, '-p', '#{alternate_on}'])
43+
return result.trim() === '1'
44+
} catch {
45+
return false
46+
}
47+
}
48+
49+
export interface ChatMessage {
50+
role: 'user' | 'assistant'
51+
text: string
52+
timestamp: string
53+
}
54+
55+
const TOOL_ICONS: Record<string, string> = {
56+
Read: '\u{1F4C4}',
57+
Edit: '\u{270F}\u{FE0F}',
58+
Write: '\u{1F4DD}',
59+
Bash: '\u{1F4BB}',
60+
Grep: '\u{1F50D}',
61+
Glob: '\u{1F4C2}',
62+
Agent: '\u{1F916}',
63+
WebFetch: '\u{1F310}',
64+
WebSearch: '\u{1F50E}',
65+
TodoWrite: '\u{1F4CB}'
66+
}
67+
68+
function encodeCwd(cwd: string): string {
69+
return cwd.replace(/[/.]/g, '-')
70+
}
71+
72+
async function findSessionJsonl(target: string): Promise<string | null> {
73+
try {
74+
const info = await run([
75+
'display-message',
76+
'-t',
77+
target,
78+
'-p',
79+
'#{pane_pid}|#{pane_current_path}'
80+
])
81+
const [pid, cwd] = info.trim().split('|')
82+
83+
const claudeDir = join(homedir(), '.claude')
84+
const sessionsDir = join(claudeDir, 'sessions')
85+
86+
// Try direct PID match
87+
try {
88+
const data = JSON.parse(await readFile(join(sessionsDir, `${pid}.json`), 'utf-8'))
89+
const jsonlPath = join(claudeDir, 'projects', encodeCwd(data.cwd), `${data.sessionId}.jsonl`)
90+
if (existsSync(jsonlPath)) return jsonlPath
91+
} catch {
92+
// PID doesn't match directly
93+
}
94+
95+
// Scan session files for matching CWD, pick most recent
96+
try {
97+
const files = await readdir(sessionsDir)
98+
let best: { path: string; startedAt: number } | null = null
99+
100+
for (const file of files) {
101+
if (!file.endsWith('.json')) continue
102+
try {
103+
const data = JSON.parse(await readFile(join(sessionsDir, file), 'utf-8'))
104+
if (data.cwd === cwd) {
105+
const jsonlPath = join(
106+
claudeDir,
107+
'projects',
108+
encodeCwd(data.cwd),
109+
`${data.sessionId}.jsonl`
110+
)
111+
if (existsSync(jsonlPath) && (!best || data.startedAt > best.startedAt)) {
112+
best = { path: jsonlPath, startedAt: data.startedAt }
113+
}
114+
}
115+
} catch {
116+
/* skip */
117+
}
118+
}
119+
return best?.path ?? null
120+
} catch {
121+
return null
122+
}
123+
} catch {
124+
return null
125+
}
126+
}
127+
128+
function formatToolUse(block: { name?: string; input?: Record<string, string> }): string {
129+
const name = block.name ?? 'Tool'
130+
const icon = TOOL_ICONS[name] ?? '\u{1F527}'
131+
const input = block.input ?? {}
132+
133+
if ((name === 'Read' || name === 'Edit' || name === 'Write') && input.file_path) {
134+
return `${icon} ${name} ${input.file_path.split('/').pop()}`
135+
}
136+
if (name === 'Bash' && input.command) {
137+
return `${icon} ${input.command.slice(0, 60)}`
138+
}
139+
if (name === 'Grep' && input.pattern) {
140+
return `${icon} Grep "${input.pattern.slice(0, 40)}"`
141+
}
142+
return `${icon} ${name}`
143+
}
144+
145+
export async function getConversationLog(target: string): Promise<ChatMessage[]> {
146+
const jsonlPath = await findSessionJsonl(target)
147+
if (!jsonlPath) return []
148+
149+
try {
150+
const raw = await readFile(jsonlPath, 'utf-8')
151+
const messages: ChatMessage[] = []
152+
153+
for (const line of raw.split('\n')) {
154+
if (!line.trim()) continue
155+
try {
156+
const record = JSON.parse(line)
157+
158+
if (record.type === 'user' && record.message?.role === 'user') {
159+
const text =
160+
typeof record.message.content === 'string' ? record.message.content.trim() : ''
161+
if (text) {
162+
messages.push({ role: 'user', text, timestamp: record.timestamp ?? '' })
163+
}
164+
} else if (record.type === 'assistant' && record.message?.role === 'assistant') {
165+
const blocks = record.message.content
166+
if (!Array.isArray(blocks)) continue
167+
168+
const parts: string[] = []
169+
for (const block of blocks) {
170+
if (block.type === 'text' && block.text?.trim()) {
171+
parts.push(block.text.trim())
172+
} else if (block.type === 'tool_use') {
173+
parts.push(formatToolUse(block))
174+
}
175+
}
176+
177+
if (parts.length > 0) {
178+
const last = messages[messages.length - 1]
179+
if (last?.role === 'assistant') {
180+
// Merge consecutive assistant messages (same turn)
181+
last.text += '\n' + parts.join('\n')
182+
last.timestamp = record.timestamp ?? last.timestamp
183+
} else {
184+
messages.push({
185+
role: 'assistant',
186+
text: parts.join('\n'),
187+
timestamp: record.timestamp ?? ''
188+
})
189+
}
190+
}
191+
}
192+
} catch {
193+
/* skip malformed lines */
194+
}
195+
}
196+
197+
return messages
198+
} catch {
199+
return []
200+
}
201+
}
202+
21203
const TMUX_PATHS = ['/opt/homebrew/bin/tmux', '/usr/local/bin/tmux', '/usr/bin/tmux']
22204
const GIT_PATHS = ['/opt/homebrew/bin/git', '/usr/local/bin/git', '/usr/bin/git']
23205

@@ -121,7 +303,8 @@ const WAITING_PATTERNS = [
121303

122304
async function capturePaneContent(target: string): Promise<string> {
123305
try {
124-
return await run(['capture-pane', '-t', target, '-p'])
306+
const output = await run(['capture-pane', '-t', target, '-p'])
307+
return stripAnsi(output)
125308
} catch {
126309
return ''
127310
}
@@ -608,14 +791,53 @@ export async function killPane(target: string): Promise<{ success: boolean; erro
608791
}
609792

610793
function trimCliFooter(output: string): string {
611-
return output
794+
const lines = output.split('\n')
795+
796+
// Strip trailing empty lines
797+
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
798+
lines.pop()
799+
}
800+
801+
// Strip CLI footer lines from the bottom: separator, session/model info,
802+
// prompt cursor, mode indicator, token/cost counters (FLICK mode).
803+
while (lines.length > 0) {
804+
const last = lines[lines.length - 1]
805+
if (
806+
/{5,}/.test(last) ||
807+
/^\s*(Session|Model)\b/.test(last) ||
808+
/^\s*\s*$/.test(last) ||
809+
/\b(plan|compact) mode\b/.test(last) ||
810+
// FLICK mode renders token/cost counters and message counts in the footer
811+
/\d+\s*tokens?\b/i.test(last) ||
812+
/\$[\d.]+\s*(cost|spent)/i.test(last) ||
813+
// Keybinding hints that appear at the bottom of FLICK TUI
814+
/^\s*(Ctrl|Esc|Enter)\b.*\b(send|cancel|submit|menu)\b/i.test(last) ||
815+
// Empty input area indicator
816+
/^\s*>\s*$/.test(last)
817+
) {
818+
lines.pop()
819+
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
820+
lines.pop()
821+
}
822+
} else {
823+
break
824+
}
825+
}
826+
827+
return lines.join('\n')
612828
}
613829

614830
export async function capturePane(target: string): Promise<string> {
615831
if (!TARGET_PATTERN.test(target)) return ''
616832
try {
617-
const output = await run(['capture-pane', '-t', target, '-p', '-S', '-500'])
618-
return trimCliFooter(output)
833+
const altScreen = await isAlternateScreen(target)
834+
// Alternate screen (FLICK / NO_FLICKER mode) has no scrollback history,
835+
// so -S -500 is useless. Just capture the current visible screen.
836+
const args = altScreen
837+
? ['capture-pane', '-t', target, '-p']
838+
: ['capture-pane', '-t', target, '-p', '-S', '-500']
839+
const output = await run(args)
840+
return trimCliFooter(stripAnsi(output))
619841
} catch {
620842
return ''
621843
}
@@ -663,5 +885,6 @@ export async function ensureShellPane(
663885
export const _testInternals = {
664886
parseChoices,
665887
detectStatusClaude,
666-
trimCliFooter
888+
trimCliFooter,
889+
stripAnsi
667890
}

0 commit comments

Comments
 (0)