diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 4611b25..f7c2da6 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -63,6 +63,11 @@ jobs:
run: node qa/maintenance.mjs
env:
DEVDECK_EXECUTABLE: release/win-unpacked/DevDeck.exe
+ - name: Verify packaged Claude status signal, What's new and cockpit tools
+ if: runner.os == 'Windows'
+ run: node qa/cockpit-tools.mjs
+ env:
+ DEVDECK_EXECUTABLE: release/win-unpacked/DevDeck.exe
- name: Verify packaged host and viewer session consistency
if: runner.os == 'Windows'
run: node qa/session-sync.mjs
@@ -76,6 +81,7 @@ jobs:
qa/shots/resilience/
qa/shots/maintenance/
qa/shots/session-sync/
+ qa/shots/cockpit-tools/
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-${{ matrix.os }}
diff --git a/README.md b/README.md
index cc4cd14..fe7c0ba 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ See what needs attention, resume the right conversation, and work on a paired PC
-> **1.41.0** adds quick replies (one click or Ctrl+Shift+1…9 for the phrases you keep typing), a session timeline of your prompts with their times, replies and images, red boxes and arrows drawn on a screenshot and sent straight back to the session, and text, log and PDF attachments for sessions on another PC ([release notes](docs/releases/1.41.0.md)). 1.40.0 ties each screenshot to the terminal row it appeared on — hover a highlighted `[Image]` for a thumbnail, click for the full view, and jump from the viewer back to the row — says what led to each image, previews new screenshots, copies images, and adds Ctrl+↑/↓ between your prompts ([release notes](docs/releases/1.40.0.md)). 1.39.0 shows the screenshots and images a Claude session worked with — Claude Desktop shows them inline; DevDeck puts them in a strip beside the terminal, also for sessions on another PC — adds 📎 / drag-and-drop attachments (images travel to a remote PC), **Update all** for Claude Code on Windows, staggered session restores that no longer stall a PC, and ends the daily false "Login expired" ([release notes](docs/releases/1.39.0.md)). 1.38.0 made closing a session reliable across paired PCs ([notes](docs/releases/1.38.0.md)). See [the maintenance review](docs/quality-review-2026-09-08.md) and [session synchronization review](docs/session-sync-review-2026-09-08.md).
+> **1.42.0** shows what changed — a What's new window after each update, with Show me, and NEW dots on new buttons until you try them — and, as an experimental setting on Windows, lets Claude Code report its own state (working, needs approval, asking, done) instead of DevDeck reading the screen ([release notes](docs/releases/1.42.0.md)). 1.41.0 adds quick replies (one click or Ctrl+Shift+1…9 for the phrases you keep typing), a session timeline of your prompts with their times, replies and images, red boxes and arrows drawn on a screenshot and sent straight back to the session, and text, log and PDF attachments for sessions on another PC ([release notes](docs/releases/1.41.0.md)). 1.40.0 ties each screenshot to the terminal row it appeared on — hover a highlighted `[Image]` for a thumbnail, click for the full view, and jump from the viewer back to the row — says what led to each image, previews new screenshots, copies images, and adds Ctrl+↑/↓ between your prompts ([release notes](docs/releases/1.40.0.md)). 1.39.0 shows the screenshots and images a Claude session worked with — Claude Desktop shows them inline; DevDeck puts them in a strip beside the terminal, also for sessions on another PC — adds 📎 / drag-and-drop attachments (images travel to a remote PC), **Update all** for Claude Code on Windows, staggered session restores that no longer stall a PC, and ends the daily false "Login expired" ([release notes](docs/releases/1.39.0.md)). 1.38.0 made closing a session reliable across paired PCs ([notes](docs/releases/1.38.0.md)). See [the maintenance review](docs/quality-review-2026-09-08.md) and [session synchronization review](docs/session-sync-review-2026-09-08.md).

diff --git a/docs/quality.md b/docs/quality.md
index d525d12..a4a1ac8 100644
--- a/docs/quality.md
+++ b/docs/quality.md
@@ -32,7 +32,7 @@ Results and screenshots are written to `qa/shots/maintenance/` and `qa/shots/res
The CI performance gate is `qa:perf-gate` (Windows, every push): a fixture agent instead of a real one, and units that do not depend on the runner's speed — running CSS animations and DOM mutation records on an idle deck (both must be ~0), DOM mutations outside the terminals while two tiles repaint at 10fps (a per-chunk sidebar rebuild is ~8,000 against a limit of 400), and the main process's longest event-loop stall while working and while 20,000 lines stream. Each limit was checked by injecting the regression it guards against: an infinite CSS animation, a sidebar render per PTY chunk, and a 700ms synchronous block in the main process all fail it. Results go to `qa/shots/perf-gate/results.json`.
-`qa:images` (Windows) drives the cockpit image strip, full-size viewer and 📎 attach against an isolated home holding a transcript with a tool screenshot, a pasted image and a written image file; its fixture `claude` echoes what it is sent, so an attached path is read back off the terminal. `qa:update` (Windows) checks "Update all": a fixture `claude` prints Claude Code's failed-update footer, and the harness asserts that `claude update` ran with no session process alive and that every conversation was resumed by id. `qa:images-remote` (Windows) pairs a host and a viewer over real TLS and checks the same from the viewer: thumbnails and the full view cross the link, an attached JPEG lands on the host as PNG with the host path typed, and a non-image named `.png` or an unindexed id is refused over the link. `qa:restore-pacing` (Windows) checks that restored agents start one at a time: a fixture that draws for 2s while booting must not see the next one launched before it finished (measured before the fix: 26–49ms apart). `qa:image-rows` (Windows) ties images to the terminal: a fixture `claude` draws a screen the way Claude Code does (`● server - tool (MCP)` calls, `⎿ [Image]` results, shaded `❯` prompts, a composer between rules) over a transcript holding the same screenshots, and the harness checks that each placeholder is marked and opens its own image, that the viewer shows what led to an image and scrolls the terminal to its row, that copying puts an image on the clipboard (the person's clipboard is restored afterwards), that a new screenshot is previewed while the strip is closed, and that Ctrl+Up/Down steps between sent prompts without entering the composer. `qa:cockpit-tools` (Windows) uses a fixture `claude` that records every byte typed into it: a quick reply from the header menu and from Ctrl+Shift+
arrives as the phrase followed by Enter, phrases edited in Settings are saved and offered, the timeline lists each prompt with its time, reply and image count and scrolls the terminal to it without resizing it, and a screenshot marked up in the viewer reaches the session as a PNG that has the red marks in it. `qa:images-remote` also sends a log file from the viewer and checks it lands on the host under its own name. None of these starts a real agent.
+`qa:images` (Windows) drives the cockpit image strip, full-size viewer and 📎 attach against an isolated home holding a transcript with a tool screenshot, a pasted image and a written image file; its fixture `claude` echoes what it is sent, so an attached path is read back off the terminal. `qa:update` (Windows) checks "Update all": a fixture `claude` prints Claude Code's failed-update footer, and the harness asserts that `claude update` ran with no session process alive and that every conversation was resumed by id. `qa:images-remote` (Windows) pairs a host and a viewer over real TLS and checks the same from the viewer: thumbnails and the full view cross the link, an attached JPEG lands on the host as PNG with the host path typed, and a non-image named `.png` or an unindexed id is refused over the link. `qa:restore-pacing` (Windows) checks that restored agents start one at a time: a fixture that draws for 2s while booting must not see the next one launched before it finished (measured before the fix: 26–49ms apart). `qa:image-rows` (Windows) ties images to the terminal: a fixture `claude` draws a screen the way Claude Code does (`● server - tool (MCP)` calls, `⎿ [Image]` results, shaded `❯` prompts, a composer between rules) over a transcript holding the same screenshots, and the harness checks that each placeholder is marked and opens its own image, that the viewer shows what led to an image and scrolls the terminal to its row, that copying puts an image on the clipboard (the person's clipboard is restored afterwards), that a new screenshot is previewed while the strip is closed, and that Ctrl+Up/Down steps between sent prompts without entering the composer. `qa:cockpit-tools` (Windows) uses a fixture `claude` that records every byte typed into it: a quick reply from the header menu and from Ctrl+Shift+ arrives as the phrase followed by Enter, phrases edited in Settings are saved and offered, the timeline lists each prompt with its time, reply and image count and scrolls the terminal to it without resizing it, and a screenshot marked up in the viewer reaches the session as a PNG that has the red marks in it. It also starts as an install updated from 1.41.0 with the Claude status signal on: the What's new window lists only what is newer and Show me rings the setting, NEW dots go once a button is used, and the fixture asks for status the way Claude Code does (OSC 7501) — the host answers on the bundled ConPTY and the header shows working, needs approval and done. The release workflow runs it against the packaged app too. `qa:images-remote` also sends a log file from the viewer and checks it lands on the host under its own name. None of these starts a real agent.
Do not run these against a live development conversation. Do not equate a short loopback test with sleep/resume, VPN changes or multi-day field validation.
diff --git a/docs/releases/1.42.0.md b/docs/releases/1.42.0.md
new file mode 100644
index 0000000..aee9a88
--- /dev/null
+++ b/docs/releases/1.42.0.md
@@ -0,0 +1,45 @@
+# DevDeck 1.42.0
+
+## See what changed
+
+- **What's new, once after each update.** A window lists what is new since the version you last ran, with where each feature is and a **Show me** button. Show me takes you there and rings the button or setting. It does not open on a first install. Settings → About → **What's new** opens it again at any time, listing everything since 1.39.0.
+- **NEW dots.** New buttons carry a small orange dot until you first use them:
+ - quick replies, the timeline and the image strip in a session's header
+ - Mark up in the image viewer
+ - 📎 for a session on another PC
+ - the new setting below
+
+## Claude tells DevDeck what it is doing (experimental)
+
+DevDeck has always guessed a session's state by reading its screen: the spinner, the prompt, how long since the last output. Claude Code 2.1.295 and later can report its own state to a terminal that asks for it, and DevDeck now asks.
+
+- The session header shows what Claude reports:
+ - **Working**, with a percentage when it knows one
+ - **Needs your approval**, **Asking you a question** or **Needs sign-in**, in amber
+ - **Done**, with a line on what it just did
+ - **Stopped on an error**
+- The sidebar and tray use the same report, so "needs you" no longer depends on how the screen happens to look.
+- The Windows notification says why the session is waiting, for example "Claude is waiting for your approval".
+
+**On Windows this is off until you turn it on:** Settings → **Claude status signal (experimental)**.
+
+- **Why it is opt-in:** Windows' own console layer (ConPTY) answers the question Claude Code asks and drops DevDeck's reply, so the report never arrives. With the setting on, new sessions run on the newer ConPTY that ships with DevDeck, the one Windows Terminal uses.
+- **When it applies:** sessions started after you turn it on.
+- **If something looks wrong:** turn it off and restart the session.
+
+On macOS and Linux there is no console layer in between, so the report is always used.
+
+## Under the hood
+
+- **Checked against the real Claude Code 2.1.306:**
+ - the probe is answered once, by DevDeck itself, before any window sees the output
+ - the states went idle → working → done, with a line on what it did
+ - with Windows' own ConPTY the reply never reaches the program, and with the bundled one it does
+- **Bundled ConPTY in the installer:** if its files are missing, sessions fall back to Windows' own ConPTY and the status signal is simply not used.
+- **Fixed before release:** the wrapper that closes a session's whole process tree was hiding which ConPTY a session ran on.
+- **New automated checks (`qa:cockpit-tools`):**
+ - What's new and Show me
+ - the NEW dots
+ - the status in the header: working, needs approval, done
+ - quick replies, the timeline and markup, all on the bundled ConPTY
+- **Packaged-build check:** the release workflow now runs `qa:cockpit-tools` against the packaged Windows app as well.
diff --git a/package-lock.json b/package-lock.json
index eea1436..d218962 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "devdeck",
- "version": "1.41.0",
+ "version": "1.42.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "devdeck",
- "version": "1.41.0",
+ "version": "1.42.0",
"license": "MIT",
"dependencies": {
"@fontsource-variable/geist": "5.3.0",
diff --git a/package.json b/package.json
index f3e9747..3c21175 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "devdeck",
- "version": "1.41.0",
+ "version": "1.42.0",
"description": "Project command deck — at-a-glance state + Claude Code, Codex, and Antigravity resume",
"main": "dist/main/main.js",
"type": "commonjs",
diff --git a/qa/cockpit-tools.mjs b/qa/cockpit-tools.mjs
index 4f0472f..845ea6f 100644
--- a/qa/cockpit-tools.mjs
+++ b/qa/cockpit-tools.mjs
@@ -59,21 +59,36 @@ out(${JSON.stringify(SHADE)} + '❯ 다음 단계' + ${JSON.stringify(RESET)} +
out(filler('second', 70));
out('────────────────────────────────\\n❯ \\n────────────────────────────────\\n');
out('FIXTURE_READY\\n');
+// Claude Code's status protocol (OSC 7501): ask the terminal, then report once answered.
+const status = (fields) => process.stdout.write('\\x1b]7501;' + fields + '\\x07');
+const b64 = (s) => Buffer.from(s).toString('base64');
+process.stdout.write('\\x1b]7501;?\\x07');
+let seenInput = '';
if (process.stdin.isTTY) process.stdin.setRawMode(true);
-process.stdin.on('data', (d) => fs.appendFileSync(${JSON.stringify(typedLog)}, d));
+process.stdin.on('data', (d) => {
+ fs.appendFileSync(${JSON.stringify(typedLog)}, d);
+ seenInput += d.toString();
+ if (seenInput.includes('\\x1b]7501;?')) { seenInput = seenInput.replace('\\x1b]7501;?', ''); status('state=working:app=claude-code:progress=30:msg=' + b64('테스트 실행 중')); }
+ if (seenInput.includes('BLOCKME')) { seenInput = ''; status('state=blocked:app=claude-code:kind=permission:msg=' + b64('Bash')); }
+ if (seenInput.includes('DONEME')) { seenInput = ''; status('state=done:app=claude-code:msg=' + b64('로그인 버그를 고쳤습니다')); }
+});
setInterval(() => {}, 1 << 30);
`);
writeFileSync(join(bin, 'claude.cmd'), '@echo off\r\nnode "%~dp0agent.js" %*\r\n');
writeFileSync(join(profile, 'state.json'), JSON.stringify({ projects: {}, settings: {
language: 'ko', folders: [{ path: repo, kind: 'repo' }], viewMode: 'list',
+ // An install updated from 1.41.0, with Claude's status signal turned on.
+ whatsNewSeen: '1.41.0', programStatus: true,
cockpitSessions: [{ tileId: 'tile-tools-1', projectPath: repo, name: 'tools-workspace', sessionId: sid, agentId: 'claude', label: 'Tools QA', lastActiveMs: Date.now() }],
} }));
const env = { ...process.env, HOME: home, USERPROFILE: home, CLAUDECODE: '', CLAUDE_CODE_SSE_PORT: '', CLAUDE_CODE_ENTRYPOINT: '' };
const pathKey = Object.keys(env).find((k) => k.toLowerCase() === 'path') || 'PATH';
env[pathKey] = bin + ';' + env[pathKey];
-const app = await electron.launch({ args: ['.', '--user-data-dir=' + profile, '--no-sandbox', '--disable-gpu'], cwd: root, env });
+// DEVDECK_EXECUTABLE runs it against a packaged build (release.yml), where the bundled ConPTY is what ships.
+const executablePath = process.env.DEVDECK_EXECUTABLE;
+const app = await electron.launch({ ...(executablePath ? { executablePath: resolve(executablePath) } : {}), args: [...(executablePath ? [] : ['.']), '--user-data-dir=' + profile, '--no-sandbox', '--disable-gpu'], cwd: root, env });
const errors = [];
let failed = false;
const check = async (name, fn) => { await fn(); console.log('PASS ' + name); };
@@ -89,6 +104,23 @@ try {
win.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
const topRow = () => win.evaluate(() => document.querySelector('.ck-term.show .xterm-rows > div')?.textContent ?? '');
+ const version = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version;
+ await check('after an update, What\'s new lists what changed and shows where it is', async () => {
+ await win.waitForSelector('.wn-dialog', { timeout: 20000 });
+ assert.equal(await win.locator('#wn-title').textContent(), `DevDeck ${version}의 새로운 기능`);
+ // Only what is newer than the version the person last saw.
+ const versions = await win.locator('.wn-version').allTextContents();
+ assert.ok(versions.length >= 1 && !versions.includes('v1.41.0'), versions.join(','));
+ await win.screenshot({ path: join(shots, '0-whats-new.png') });
+ await win.locator('.wn-card', { hasText: 'Claude가 직접 상태를 알려 줍니다' }).locator('.wn-show').click();
+ await win.waitForSelector('.wn-dialog', { state: 'detached' });
+ await win.waitForSelector('#settings-form .set-row.dd-spotlight #set-program-status');
+ assert.equal(await win.locator('#set-program-status').isChecked(), true);
+ await win.screenshot({ path: join(shots, '0-spotlight.png') });
+ const seen = JSON.parse(readFileSync(join(profile, 'state.json'), 'utf8')).settings.whatsNewSeen;
+ assert.equal(seen, version, 'not shown again for this version');
+ });
+
await check('session opens with the fixture screen', async () => {
await win.waitForSelector('#shell-session-groups .shell-session', { timeout: 30000 });
await win.locator('#shell-session-groups .shell-session').first().click();
@@ -96,8 +128,25 @@ try {
await win.waitForFunction(() => document.querySelector('#ck-header .ck-img-count')?.textContent === '1');
});
+ await check('Claude\'s own status shows in the header: working, needs approval, done', async () => {
+ // The host answered the probe, so the agent reports.
+ await waitTyped('\x1b]7501;?');
+ const pill = win.locator('#ck-header .ck-status-pill');
+ await win.waitForFunction(() => document.querySelector('#ck-header .ck-status-pill')?.textContent?.includes('작업 중 30%'));
+ assert.match(await pill.textContent(), /테스트 실행 중/);
+ await win.locator('.ck-term.show .xterm-screen').click();
+ await win.keyboard.type('BLOCKME');
+ await win.waitForFunction(() => document.querySelector('#ck-header .ck-status-pill.st-blocked')?.textContent?.includes('승인 필요'));
+ await win.screenshot({ path: join(shots, '1-status-blocked.png') });
+ await win.keyboard.type('DONEME');
+ await win.waitForFunction(() => document.querySelector('#ck-header .ck-status-pill.st-done')?.textContent?.includes('로그인 버그를 고쳤습니다'));
+ await win.screenshot({ path: join(shots, '1-status-done.png') });
+ });
+
await check('a quick reply from the menu is typed and sent', async () => {
const button = win.locator('#ck-header button[aria-label="빠른 답장"]');
+ // New since the last version the person ran: marked until it is used.
+ assert.equal(await button.locator('.dd-new-dot').count(), 1, 'a NEW dot on the quick reply button');
// Waiting for the person: the button says so.
await win.waitForFunction(() => document.querySelector('#ck-header .ck-qr-btn')?.classList.contains('ready'), null, { timeout: 20000 });
await button.click();
@@ -107,6 +156,7 @@ try {
await win.locator('.ck-qr-item').first().click();
await waitTyped('네 계속 진행해줘\r');
assert.equal(await win.locator('.ck-qr-menu').count(), 0, 'the menu closed');
+ assert.equal(await win.locator('#ck-header .ck-qr-btn .dd-new-dot').count(), 0, 'the NEW dot went once it was used');
});
await check('Ctrl+Shift+ sends the n-th quick reply from the terminal', async () => {
diff --git a/src/main/api/deckApi.close.test.ts b/src/main/api/deckApi.close.test.ts
index 90cb909..ecff90f 100644
--- a/src/main/api/deckApi.close.test.ts
+++ b/src/main/api/deckApi.close.test.ts
@@ -25,6 +25,7 @@ function makePtys() {
sessions.set(id, { onExit });
},
list() { return [...sessions.keys()].map((id) => ({ id, projectPath: ALLOWED_ROOT, sessionId: null, agentId: 'claude', startedAtMs: 0, label: null })); },
+ programStatus() { return false; },
kill(id: string) {
const s = sessions.get(id); if (!s) return;
sessions.delete(id); this.killed.push(id);
@@ -42,7 +43,7 @@ function build(link: Link | null) {
const bundle = createDeckApi({
win: { on: () => {}, isDestroyed: () => true, webContents: { send: () => {} } },
defaultBaseDir: ALLOWED_ROOT,
- store: { getFolders: () => [{ path: ALLOWED_ROOT, kind: 'root' }], getAgent: () => 'claude', getSessionSummary: () => true, getAiSessionSummary: () => false, getCockpitSessions: () => [], setCockpitSessions: () => {}, setLastOpened: () => {} },
+ store: { getFolders: () => [{ path: ALLOWED_ROOT, kind: 'root' }], getAgent: () => 'claude', getSessionSummary: () => true, getAiSessionSummary: () => false, getProgramStatus: () => false, getCockpitSessions: () => [], setCockpitSessions: () => {}, setLastOpened: () => {} },
sendError: vi.fn(), defaultLanguage: 'en', ptyHost: ptys, ptyAvailable: true,
tray: { applyCounts: vi.fn(), setAlertImage: vi.fn() }, shutdown: null, shutdownLog: null, bootTimeMs: () => 0,
link: () => link,
diff --git a/src/main/api/deckApi.projects.test.ts b/src/main/api/deckApi.projects.test.ts
index 8acaedf..c62ce98 100644
--- a/src/main/api/deckApi.projects.test.ts
+++ b/src/main/api/deckApi.projects.test.ts
@@ -42,7 +42,7 @@ beforeAll(() => {
getThresholds: () => null,
get: (p: string): StoreEntry => ({ note: '', pinned: false, hidden: false, lastOpened: null, todos: [], ...entries.get(p) }),
getSessionSummary: () => true,
- getAiSessionSummary: () => false,
+ getAiSessionSummary: () => false, getProgramStatus: () => false,
},
sendError: vi.fn(),
defaultLanguage: 'en',
diff --git a/src/main/api/deckApi.surface.test.ts b/src/main/api/deckApi.surface.test.ts
index 67a80d9..0c584fe 100644
--- a/src/main/api/deckApi.surface.test.ts
+++ b/src/main/api/deckApi.surface.test.ts
@@ -57,7 +57,7 @@ beforeAll(() => {
getTrayAlert: () => 'attention',
getShutdownIdleMinutes: () => 10,
getSessionSummary: () => true,
- getAiSessionSummary: () => false,
+ getAiSessionSummary: () => false, getProgramStatus: () => false,
},
sendError: vi.fn(),
defaultLanguage: 'en',
diff --git a/src/main/api/deckApi.ts b/src/main/api/deckApi.ts
index d38d09e..61745c4 100644
--- a/src/main/api/deckApi.ts
+++ b/src/main/api/deckApi.ts
@@ -399,7 +399,11 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle {
cockpitSidebarCollapsed: cfg.store.getCockpitSidebarCollapsed(),
sessionSummary: cfg.store.getSessionSummary(), aiSessionSummary: cfg.store.getAiSessionSummary(),
quickReplies: cfg.store.getQuickReplies(),
+ programStatus: cfg.store.getProgramStatus(),
+ whatsNewSeen: cfg.store.getWhatsNewSeen(), usedBefore: cfg.store.hasUsedBefore(),
}));
+ invoke('settings:setProgramStatus', localOnly, (on: boolean) => cfg.store.setProgramStatus(on === true));
+ invoke('settings:setWhatsNewSeen', localOnly, (v: unknown) => cfg.store.setWhatsNewSeen(String(v)));
invoke('settings:setSessionSummary', localOnly, (on: boolean) => cfg.store.setSessionSummary(on === true));
invoke('settings:setQuickReplies', localOnly, (list: unknown) => cfg.store.setQuickReplies(list));
invoke('settings:setAiSessionSummary', localOnly, (on: boolean) => {
@@ -691,12 +695,14 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle {
emit('cockpit:exit', closedBy ? { id, exitCode: e.exitCode, closed: true, closedBy } : { id, exitCode: e.exitCode });
publishSessions();
},
- { projectPath: req.projectPath, sessionId: resolved.sessionId, agentId: a.id },
+ // Claude Code's own status report: always asked for off Windows; on Windows only when the
+ // person turned it on, because it moves the session onto the bundled ConPTY.
+ { projectPath: req.projectPath, sessionId: resolved.sessionId, agentId: a.id, programStatus: a.id === 'claude' && (process.platform !== 'win32' || cfg.store.getProgramStatus()) },
);
// Every terminal this machine starts, and WHY. A session appearing that nobody opened is the
// shape of more than one bug we have shipped, and until this line there was no way to tell a
// restore from a deck click from a paired machine's request after the fact.
- cfg.diagnostics?.write('info', 'pty', `open ${id} agent=${a.id} mode=${req.mode ?? 'auto'} conversation=${resolved.sessionId ?? '-'} cmd=${resolved.command}`);
+ cfg.diagnostics?.write('info', 'pty', `open ${id} agent=${a.id} mode=${req.mode ?? 'auto'} conversation=${resolved.sessionId ?? '-'} status=${cfg.ptyHost.programStatus(id) ? 'on' : 'off'} cmd=${resolved.command}`);
publishSessions();
cfg.store.setLastOpened(req.projectPath, new Date().toISOString());
const started = cfg.ptyHost.list().find(s => s.id === id);
diff --git a/src/main/ipc.cockpit.test.ts b/src/main/ipc.cockpit.test.ts
index 3ada73b..a516f92 100644
--- a/src/main/ipc.cockpit.test.ts
+++ b/src/main/ipc.cockpit.test.ts
@@ -70,12 +70,12 @@ beforeAll(() => {
defaultBaseDir: ALLOWED_ROOT,
store: {
getFolders: () => [{ path: ALLOWED_ROOT, kind: 'root' }], getAgent: () => storedAgent,
- getSessionSummary: () => true, getAiSessionSummary: () => false,
+ getSessionSummary: () => true, getAiSessionSummary: () => false, getProgramStatus: () => false,
getThresholds: () => null,
},
sendError: vi.fn(),
defaultLanguage: 'en',
- ptyHost: { create: ptyCreate, list: () => [], pid: (id: string) => (id === 'live#1' ? 4321 : null), note: ptyNote },
+ ptyHost: { create: ptyCreate, list: () => [], programStatus: () => false, pid: (id: string) => (id === 'live#1' ? 4321 : null), note: ptyNote },
ptyAvailable: true,
tray: {},
shutdown: null,
diff --git a/src/main/ipc.guard.test.ts b/src/main/ipc.guard.test.ts
index 4040077..96e1e02 100644
--- a/src/main/ipc.guard.test.ts
+++ b/src/main/ipc.guard.test.ts
@@ -62,7 +62,7 @@ beforeAll(() => {
store: {
getFolders: () => [{ path: ALLOWED_ROOT, kind: 'root' }], getTrayAlert: () => 'attention',
getShutdownIdleMinutes: () => 10,
- getSessionSummary: () => true, getAiSessionSummary: () => false,
+ getSessionSummary: () => true, getAiSessionSummary: () => false, getProgramStatus: () => false,
...storeSpies,
},
sendError,
@@ -308,7 +308,7 @@ describe('effFolders fallback when no folders are configured', () => {
store: {
getFolders: () => [], getTrayAlert: () => 'attention',
getShutdownIdleMinutes: () => 10,
- getSessionSummary: () => true, getAiSessionSummary: () => false,
+ getSessionSummary: () => true, getAiSessionSummary: () => false, getProgramStatus: () => false,
...storeSpies,
},
sendError,
diff --git a/src/main/main.ts b/src/main/main.ts
index 212896c..520ce1a 100644
--- a/src/main/main.ts
+++ b/src/main/main.ts
@@ -1,5 +1,6 @@
import { app, BrowserWindow, globalShortcut, crashReporter, powerMonitor, powerSaveBlocker, safeStorage, screen } from 'electron';
import * as path from 'node:path';
+import * as fs from 'node:fs';
import { spawn } from 'node:child_process';
import { uptime, homedir, tmpdir } from 'node:os';
import { Store } from './store';
@@ -34,7 +35,10 @@ try { nodePty = require('@homebridge/node-pty-prebuilt-multiarch') as NodePty; }
const realSpawn: PtySpawn = (file, args, opts) => {
if (!nodePty) throw new Error('node-pty native binding unavailable — embedded terminals need Windows (or a matching prebuilt)');
- const p = nodePty.spawn(file, args, { name: 'xterm-256color', cwd: opts.cwd, cols: opts.cols, rows: opts.rows });
+ // The bundled conpty.dll is asked for only when it is actually there (it is copied in by
+ // node-pty's postinstall); without it the terminal falls back to Windows' own ConPTY.
+ const bundled = opts.bundledConpty === true && bundledConptyAvailable();
+ const p = nodePty.spawn(file, args, { name: 'xterm-256color', cwd: opts.cwd, cols: opts.cols, rows: opts.rows, useConptyDll: bundled });
const proc: PtyProcess = {
pid: p.pid,
onData: (cb) => { p.onData(cb); },
@@ -42,11 +46,21 @@ const realSpawn: PtySpawn = (file, args, opts) => {
write: (d) => p.write(d),
resize: (c, r) => p.resize(c, r),
kill: () => p.kill(),
+ bundledConpty: bundled,
};
// Closing a tile has to end what the agent started too (MCP servers, hidden dev servers), which
// node-pty's console-only kill leaves running on Windows — see withTreeKill.
return process.platform === 'win32' ? withTreeKill(proc, (pid) => taskkillTree(pid)) : proc;
};
+let conptyDll: boolean | null = null;
+function bundledConptyAvailable(): boolean {
+ if (conptyDll !== null) return conptyDll;
+ try {
+ const dir = path.join(path.dirname(require.resolve('@homebridge/node-pty-prebuilt-multiarch/package.json')), 'build', 'Release', 'conpty');
+ conptyDll = fs.existsSync(path.join(dir, 'conpty.dll')) && fs.existsSync(path.join(dir, 'OpenConsole.exe'));
+ } catch { conptyDll = false; }
+ return conptyDll;
+}
const ptyHost = new PtyHost(realSpawn);
let win: BrowserWindow | null = null;
diff --git a/src/main/ptyHost.test.ts b/src/main/ptyHost.test.ts
index 5c3e4ea..af7f7fc 100644
--- a/src/main/ptyHost.test.ts
+++ b/src/main/ptyHost.test.ts
@@ -344,3 +344,50 @@ describe('Windows process-tree kill', () => {
expect(spawnFn).not.toHaveBeenCalled();
});
});
+
+describe('Claude Code status probe (OSC 7501)', () => {
+ const PROBE = '\x1b]7501;?\x07';
+ const open = (programStatus: boolean, bundledConpty: boolean) => {
+ const proc = Object.assign(fakeProc(), { bundledConpty });
+ const spawn = vi.fn(() => proc) as unknown as PtySpawn;
+ const onData = vi.fn();
+ new PtyHost(spawn).create('s', 'pwsh', [], '.', 80, 24, onData, () => {}, { programStatus });
+ return { proc, spawn, onData };
+ };
+ const onWindowsOrElsewhere = process.platform === 'win32';
+
+ it('answers the probe once, even split across chunks, and still passes the output on', () => {
+ const { proc, onData } = open(true, true);
+ proc.emitData('banner\x1b]75');
+ proc.emitData('01;?\x07\x1b[c');
+ expect(proc.write).toHaveBeenCalledTimes(1);
+ expect(proc.write).toHaveBeenCalledWith(PROBE);
+ expect(onData.mock.calls.map((c) => c[0]).join('')).toContain('\x1b]7501;?');
+ });
+
+ it('asks Windows for the bundled ConPTY only when the status is wanted', () => {
+ const on = open(true, true), off = open(false, false);
+ const opts = (s: PtySpawn) => (s as unknown as ReturnType).mock.calls[0][2];
+ expect(opts(on.spawn).bundledConpty).toBe(onWindowsOrElsewhere ? true : undefined);
+ expect(opts(off.spawn).bundledConpty).toBeUndefined();
+ });
+
+ it('never answers when the status was not asked for', () => {
+ const { proc } = open(false, false);
+ proc.emitData(PROBE);
+ expect(proc.write).not.toHaveBeenCalled();
+ });
+
+ it('on Windows, does not answer through Windows\' own ConPTY, where the answer would arrive too late', () => {
+ const { proc } = open(true, false);
+ proc.emitData(PROBE);
+ if (onWindowsOrElsewhere) expect(proc.write).not.toHaveBeenCalled();
+ else expect(proc.write).toHaveBeenCalledWith(PROBE);
+ });
+});
+
+it('the tree-kill wrapper keeps saying which ConPTY the terminal runs on', () => {
+ // Dropping it here made every session look like it ran on Windows' own ConPTY, so the status
+ // probe was never answered (found by qa:cockpit-tools).
+ expect(withTreeKill(Object.assign(fakeProc(), { bundledConpty: true }), async () => {}).bundledConpty).toBe(true);
+});
diff --git a/src/main/ptyHost.ts b/src/main/ptyHost.ts
index bfa29f3..39c7553 100644
--- a/src/main/ptyHost.ts
+++ b/src/main/ptyHost.ts
@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto';
import { spawn as spawnChild } from 'node:child_process';
import { withTimeout } from '../shared/withTimeout';
+import { hasProgramStatusProbe, PROGRAM_STATUS_REPLY } from '../shared/programStatus';
export interface PtyProcess {
pid: number;
@@ -9,6 +10,8 @@ export interface PtyProcess {
write(data: string): void;
resize(cols: number, rows: number): void;
kill(): void;
+ /** True when this terminal runs on the conpty.dll bundled with node-pty rather than Windows' own. */
+ bundledConpty?: boolean;
}
/**
@@ -21,7 +24,7 @@ export interface PtyProcess {
export interface PtyScreen { data: string; cols: number; rows: number }
export type PtySpawn = (
- file: string, args: string[], opts: { cwd: string; cols: number; rows: number }
+ file: string, args: string[], opts: { cwd: string; cols: number; rows: number; bundledConpty?: boolean }
) => PtyProcess;
/**
@@ -109,6 +112,7 @@ export function withTreeKill(proc: PtyProcess, killTree: TreeKiller): PtyProcess
let killing = false;
return {
get pid() { return proc.pid; },
+ bundledConpty: proc.bundledConpty,
onData: (cb) => proc.onData(cb),
onExit: (cb) => proc.onExit(cb),
write: (d) => proc.write(d),
@@ -150,6 +154,8 @@ interface Session {
bufferedLength: number;
/** Only a truncated prefix needs discarding through the next complete line. */
prefixTruncated: boolean;
+ /** Whether this terminal answers Claude Code's status probe (and so receives its status reports). */
+ programStatus: boolean;
}
export class PtyHost {
@@ -162,14 +168,21 @@ export class PtyHost {
create(
id: string, file: string, args: string[], cwd: string, cols: number, rows: number,
onData: (data: string) => void, onExit: (e: { exitCode: number }) => void,
- info?: Partial> & { internal?: boolean },
+ info?: Partial> & { internal?: boolean; programStatus?: boolean },
): void {
if (this.stopping) throw new Error('Terminals are shutting down');
if (this.sessions.has(id)) throw new Error(`Terminal already exists: ${id}`);
if (info?.internal !== true && this.list().length >= MAX_SESSIONS) {
throw new Error(`Too many terminals open (${MAX_SESSIONS}). Close some sessions first.`);
}
- const proc = this.spawn(file, args, { cwd, cols, rows });
+ // Claude Code reports its own state (OSC 7501) only to a terminal that answers its probe. On
+ // Windows the probe has to REACH a terminal for that: Windows' own ConPTY answers the device
+ // query Claude Code sends after it, so the probe looks unanswered before any reply can arrive.
+ // The conpty.dll bundled with node-pty passes both through.
+ const wantStatus = info?.programStatus === true;
+ const proc = this.spawn(file, args, wantStatus && process.platform === 'win32' ? { cwd, cols, rows, bundledConpty: true } : { cwd, cols, rows });
+ const answerProbe = wantStatus && (process.platform !== 'win32' || proc.bundledConpty === true);
+ let probeTail = '';
let exited!: () => void;
const exit = new Promise((resolve) => { exited = resolve; });
this.pendingExits.add(exit);
@@ -191,9 +204,20 @@ export class PtyHost {
chunks: [],
bufferedLength: 0,
prefixTruncated: false,
+ programStatus: answerProbe,
};
this.sessions.set(id, session);
- proc.onData((data) => { this.remember(session, data); onData(data); });
+ proc.onData((data) => {
+ // Answered here, once, before the output reaches any viewer: every attached terminal would
+ // otherwise answer for itself, and a session nobody is looking at would never be answered.
+ if (answerProbe) {
+ const seen = probeTail + data;
+ if (hasProgramStatusProbe(seen)) { proc.write(PROGRAM_STATUS_REPLY); probeTail = ''; }
+ else probeTail = seen.slice(-16);
+ }
+ this.remember(session, data);
+ onData(data);
+ });
proc.onExit((e) => {
if (this.sessions.get(id) === session) this.sessions.delete(id);
this.pendingExits.delete(exit);
@@ -222,6 +246,9 @@ export class PtyHost {
pid(id: string): number | null { return this.sessions.get(id)?.proc.pid ?? null; }
/** Every project session running here, for a viewer asking "what is already going on over there". */
+ /** Whether a session answers Claude Code's status probe; false for one that does not exist. */
+ programStatus(id: string): boolean { return this.sessions.get(id)?.programStatus === true; }
+
list(): PtySessionInfo[] {
return [...this.sessions.values()].filter((s) => !s.internal).map((s) => ({ ...s.info }));
}
diff --git a/src/main/store.test.ts b/src/main/store.test.ts
index 709d304..55770f7 100644
--- a/src/main/store.test.ts
+++ b/src/main/store.test.ts
@@ -403,3 +403,21 @@ describe('renameWithRetry (state.json commit on Windows)', () => {
expect(Date.now() - t0).toBeGreaterThanOrEqual(15);
});
});
+
+describe('Claude status signal and What\'s new bookkeeping', () => {
+ it('keeps the status signal off until turned on, and only records real version numbers', () => {
+ const dir = mkdtempSync(join(tmpdir(), 'devdeck-store-wn-'));
+ const file = join(dir, 'state.json');
+ const store = new Store(file);
+ expect(store.getProgramStatus()).toBe(false);
+ expect(store.getWhatsNewSeen()).toBeNull();
+ expect(store.hasUsedBefore()).toBe(false);
+ store.setProgramStatus(true);
+ store.setWhatsNewSeen('../../evil');
+ expect(store.getWhatsNewSeen()).toBeNull();
+ store.setWhatsNewSeen('1.42.0');
+ const again = new Store(file);
+ expect([again.getProgramStatus(), again.getWhatsNewSeen()]).toEqual([true, '1.42.0']);
+ rmSync(dir, { recursive: true, force: true });
+ });
+});
diff --git a/src/main/store.ts b/src/main/store.ts
index cf64df5..9c8e588 100644
--- a/src/main/store.ts
+++ b/src/main/store.ts
@@ -13,7 +13,7 @@ import { LINK_DEFAULT_PORT } from './link/protocol';
interface StateFile {
projects: Record;
- settings?: { language?: string; baseDir?: string; folders?: Folder[]; thresholds?: { freshDays: number; warnDays: number; neglectedDays: number }; agent?: string; openAtLogin?: boolean; viewMode?: 'cards' | 'list'; cockpitSessions?: PersistedSession[]; trayAlert?: 'off' | 'attention' | 'all'; pendingAutoRestore?: PersistedSession[]; contextWindow?: number; shutdownIdleMinutes?: number; cockpitSidebarCollapsed?: boolean; sessionSummary?: boolean; aiSessionSummary?: boolean; quickReplies?: string[]; windowBounds?: WindowBounds; machineId?: string; machineName?: string; linkHostMode?: boolean; linkPort?: number; linkDevices?: PairedDevice[]; linkHosts?: KnownHost[] };
+ settings?: { language?: string; baseDir?: string; folders?: Folder[]; thresholds?: { freshDays: number; warnDays: number; neglectedDays: number }; agent?: string; openAtLogin?: boolean; viewMode?: 'cards' | 'list'; cockpitSessions?: PersistedSession[]; trayAlert?: 'off' | 'attention' | 'all'; pendingAutoRestore?: PersistedSession[]; contextWindow?: number; shutdownIdleMinutes?: number; cockpitSidebarCollapsed?: boolean; sessionSummary?: boolean; aiSessionSummary?: boolean; quickReplies?: string[]; programStatus?: boolean; whatsNewSeen?: string; windowBounds?: WindowBounds; machineId?: string; machineName?: string; linkHostMode?: boolean; linkPort?: number; linkDevices?: PairedDevice[]; linkHosts?: KnownHost[] };
}
/** Error codes Windows returns while another process (AV scan, indexer, OneDrive) briefly holds the file. */
@@ -287,6 +287,14 @@ export class Store {
/** The person's quick replies, or null while they have never edited them (the defaults are per language). */
getQuickReplies(): string[] | null { const q = this.state.settings?.quickReplies; return Array.isArray(q) ? sanitizeQuickReplies(q) : null; }
setQuickReplies(list: unknown): void { this.state.settings = { ...(this.state.settings ?? {}), quickReplies: sanitizeQuickReplies(list) }; this.save(); }
+ /** Ask Claude Code for its own status (OSC 7501). On Windows this runs sessions on the bundled ConPTY, so it is opt-in there. */
+ getProgramStatus(): boolean { return this.state.settings?.programStatus === true; }
+ setProgramStatus(on: boolean): void { this.state.settings = { ...(this.state.settings ?? {}), programStatus: on === true }; this.save(); }
+ /** The version whose new features were last shown; null before 1.42.0 recorded one. */
+ getWhatsNewSeen(): string | null { const v = this.state.settings?.whatsNewSeen; return typeof v === 'string' && /^\d+\.\d+\.\d+$/.test(v) ? v : null; }
+ setWhatsNewSeen(v: string): void { if (!/^\d+\.\d+\.\d+$/.test(v)) return; this.state.settings = { ...(this.state.settings ?? {}), whatsNewSeen: v }; this.save(); }
+ /** Whether this install has been used before (a window was ever saved) — an upgrade, not a first run. */
+ hasUsedBefore(): boolean { return this.state.settings?.windowBounds !== undefined; }
getTrayAlert(): 'off' | 'attention' | 'all' { const t = this.state.settings?.trayAlert; return t === 'off' || t === 'all' ? t : 'attention'; }
setTrayAlert(t: 'off' | 'attention' | 'all'): void { this.state.settings = { ...(this.state.settings ?? {}), trayAlert: t === 'off' || t === 'all' ? t : 'attention' }; this.save(); }
diff --git a/src/preload/preload.ts b/src/preload/preload.ts
index 2b1fdb8..035d44d 100644
--- a/src/preload/preload.ts
+++ b/src/preload/preload.ts
@@ -34,6 +34,8 @@ contextBridge.exposeInMainWorld('devdeck', {
setSessionSummary: (on: boolean) => ipcRenderer.invoke('settings:setSessionSummary', on),
setAiSessionSummary: (on: boolean) => ipcRenderer.invoke('settings:setAiSessionSummary', on),
setQuickReplies: (list: string[]) => ipcRenderer.invoke('settings:setQuickReplies', list),
+ setProgramStatus: (on: boolean) => ipcRenderer.invoke('settings:setProgramStatus', on),
+ setWhatsNewSeen: (version: string) => ipcRenderer.invoke('settings:setWhatsNewSeen', version),
usageSnapshot: () => ipcRenderer.invoke('usage:snapshot'),
refreshUsageProviders: (opts?: { force?: boolean }) => ipcRenderer.invoke('usage:refresh', { force: opts?.force === true }),
openUsageLogin: (providerId: 'claude' | 'codex', cols: number, rows: number) => ipcRenderer.invoke('usage:login', providerId, cols, rows),
diff --git a/src/renderer/cockpitView.ts b/src/renderer/cockpitView.ts
index 6e09c95..bf1639a 100644
--- a/src/renderer/cockpitView.ts
+++ b/src/renderer/cockpitView.ts
@@ -34,6 +34,9 @@ import { initTimeline, isTimelineOpen, refreshTimeline, showTimelineFor, toggleT
import { pairTurnsWithRows } from '../shared/timeline';
import { isQuickReplyMenuOpen, quickReplies, setQuickReplies, toggleQuickReplyMenu } from './quickReplyMenu';
import { quickReplySlot } from '../shared/quickReplies';
+import { activityFromProgramStatus, parseProgramStatus, type ProgramStatus } from '../shared/programStatus';
+import { IDLE_MS } from '../shared/sessionStatus';
+import { newDot } from './whatsNew';
/** What cockpit:sessionMeta answers with: the log-derived facts plus the ready-made summary line. */
type SessionMetaView = { model: string | null; activeMs: number; contextTokens: number; contextWindow?: number; summary: string | null };
@@ -42,6 +45,11 @@ interface Live {
tileId: string; session: CockpitSession; term: Terminal; fit: FitAddon; search: SearchAddon; el: HTMLElement;
/** The GPU renderer, when the machine could give us one; null means xterm is drawing with DOM nodes. */
lastDataAt: number; lastInputAt: number; recentOutput: string;
+ /**
+ * What the agent itself last reported (Claude Code's OSC 7501), and since when — or null for a
+ * session that does not report, whose state is read off its screen instead.
+ */
+ native: { status: ProgramStatus; since: number } | null;
openedSessionId: string | null; openedAt: number; idCheckAt: number;
labelVersion: SessionLabelVersion | null;
customLabel: string | null; meta: SessionMetaView | null; pinned: boolean; lastSelectedAt: number;
@@ -1009,6 +1017,12 @@ async function buildTile(p: OpenReq): Promise {
return out;
}
term.registerLinkProvider(linkProvider);
+ // Claude Code's own status report. xterm draws nothing for it; the handler only records it.
+ term.parser.registerOscHandler(7501, (payload) => {
+ const status = parseProgramStatus(payload);
+ if (status) noteProgramStatus(term, status);
+ return true;
+ });
// Files dropped on the terminal are handed to the agent by path, as 📎 does (see attachments.ts).
// preventDefault on dragover is what makes this a drop target at all — without it Electron would
// navigate the window to the dropped file.
@@ -1145,7 +1159,7 @@ async function buildTile(p: OpenReq): Promise {
if (displaced && displaced.el !== el) { displaced.el.remove(); displaced.term.dispose(); }
live.set(res.id, {
machineId, tileId: adopted.tileId ?? createCockpitTileId(), session, term, fit, search, el,
- lastDataAt: Date.now(), lastInputAt: 0, recentOutput: '',
+ lastDataAt: Date.now(), lastInputAt: 0, recentOutput: '', native: null,
openedSessionId: res.sessionId ?? null, openedAt: Date.now(), idCheckAt: Date.now(),
labelVersion: hasLabelVersion(res) ? { instanceId: res.instanceId, labelRevision: res.labelRevision } : null,
customLabel: adopting ? adopted.label : null, meta: null, pinned: adopted.pinned, lastSelectedAt: Date.now(),
@@ -1838,7 +1852,10 @@ function tickActivity(): void {
const screenText = liveScreenTail(l);
// spinnerReliable: only Claude's spinner glyph is one we match, so only there can we trust "spinner
// gone ⇒ turn" and skip the timing hysteresis (avoids 작업중 lingering ~10s after each Claude turn).
- const next = l.offline && l.session.status !== 'exited' ? 'offline' : computeActivity({
+ const next = l.offline && l.session.status !== 'exited' ? 'offline'
+ // The agent's own word beats any reading of its screen.
+ : l.native && l.session.status !== 'exited' ? activityFromProgramStatus(l.native.status, l.native.since, now, IDLE_MS)
+ : computeActivity({
exited: l.session.status === 'exited', lastDataAt: l.lastDataAt, lastInputAt: l.lastInputAt, now,
recentOutput: l.recentOutput, screenText, prev: prev === 'offline' ? 'idle' : prev, spinnerReliable: l.session.agentId === 'claude',
});
@@ -2106,10 +2123,46 @@ function togglePrevPin(r: PersistedSession): void {
}
/** OS toast for "this session needs you" — clicking it raises the window and jumps to the session. */
+/** Record a status report from a session's agent, and show it at once if that session is on screen. */
+function noteProgramStatus(term: Terminal, status: ProgramStatus): void {
+ const l = [...live.values()].find((x) => x.term === term);
+ if (!l) return;
+ const prev = l.native?.status;
+ const changed = !prev || prev.state !== status.state || prev.kind !== status.kind;
+ l.native = { status, since: changed ? Date.now() : l.native!.since };
+ if (l.session.id === selectedId) {
+ const pill = headerEl?.querySelector('.ck-status-pill');
+ if (pill) fillStatusPill(pill, l);
+ }
+ if (changed) tickActivity();
+}
+
+/** What the agent says it is doing, in the header — absent for a session that does not say. */
+function fillStatusPill(pill: HTMLElement, l: Live): void {
+ const n = l.native?.status;
+ pill.hidden = !n || n.state === 'idle';
+ if (!n || n.state === 'idle') return;
+ const key = n.state === 'blocked' ? `cockpit.status_blocked_${n.kind ?? 'other'}` : `cockpit.status_${n.state}`;
+ let text = tr(key);
+ if (n.state === 'working' && n.progress !== null) text += ` ${n.progress}%`;
+ pill.className = `ck-pill ck-status-pill st-${n.state}${n.kind ? ` kind-${n.kind}` : ''}`;
+ const dot = document.createElement('span'); dot.className = 'ck-status-dot';
+ const label = document.createElement('span'); label.className = 'ck-status-text'; label.textContent = text;
+ pill.replaceChildren(dot, label);
+ if (n.msg && n.state !== 'blocked') {
+ const msg = document.createElement('span'); msg.className = 'ck-status-msg'; msg.textContent = n.msg;
+ pill.appendChild(msg);
+ }
+ pill.title = [text, n.msg, tr('cockpit.status_source')].filter(Boolean).join('\n');
+}
+
function notifyAttention(l: Live): void {
const name = liveLabels.get(l.session.id) ?? l.customLabel ?? l.session.name;
+ // When the agent said WHY it stopped, the notification says it too.
+ const kind = l.native?.status.state === 'blocked' ? l.native.status.kind : null;
+ const body = kind ? tr(`cockpit.notify_blocked_${kind}`) : tr('cockpit.notify_attention');
try {
- const n = new Notification(name, { body: tr('cockpit.notify_attention'), tag: `devdeck-attn-${l.session.id}` });
+ const n = new Notification(name, { body, tag: `devdeck-attn-${l.session.id}` });
n.onclick = () => {
void window.devdeck.windowControls.show();
const target = cockpitNavigationIdForRuntime([...live.values()].map(navigationIdentity), l.session.id);
@@ -2263,6 +2316,9 @@ function renderHeader(): void {
const pills: HTMLElement[] = [title, branch, ag];
const model = friendlyModel(l.meta?.model ?? null);
if (model) { const mp = document.createElement('span'); mp.className = 'ck-pill'; mp.textContent = model; pills.push(mp); }
+ const statusPill = document.createElement('span'); statusPill.className = 'ck-pill ck-status-pill';
+ fillStatusPill(statusPill, l);
+ pills.push(statusPill);
const ctxPct = contextPercent(l.meta?.contextTokens ?? 0, windowFor(l.meta));
if (ctxPct !== null) { const cp = document.createElement('span'); cp.className = 'ck-pill'; cp.append(createIcon('brain'), `${ctxPct}%`); cp.title = tr('cockpit.context'); pills.push(cp); }
if (l.meta && l.meta.activeMs > 0) { const tp = document.createElement('span'); tp.className = 'ck-pill'; tp.append(createIcon('clock'), formatDuration(l.meta.activeMs)); pills.push(tp); }
@@ -2279,14 +2335,20 @@ function renderHeader(): void {
});
const restart = actBtn('restart', tr('cockpit.restart'), () => restartSession(s.id));
const attach = actBtn('paperclip', tr(l.machineId === LOCAL_MACHINE_ID ? 'cockpit.attach' : 'cockpit.attach_remote'), () => pickAttachments(l.machineId, l.term));
+ attach.classList.add('ck-attach-btn');
+ // Documents are new only for a session on another PC; a local one always took any file.
+ if (l.machineId !== LOCAL_MACHINE_ID) newDot(attach, 'remote-documents');
// Quick replies: lit up when the session is waiting for the person, which is when they are for.
const reply = actBtn('reply', tr('cockpit.quick_replies'), () => toggleQuickReplyMenu(reply, (text) => sendQuickReply(s.id, text)));
reply.classList.add('ck-qr-btn');
+ newDot(reply, 'quick-replies');
reply.setAttribute('aria-haspopup', 'menu'); reply.setAttribute('aria-expanded', String(isQuickReplyMenuOpen()));
reply.classList.toggle('ready', s.activity === 'turn' || s.activity === 'idle');
const timeline: HTMLElement[] = [];
if (s.agentId === 'claude') {
const b = actBtn('timeline', tr(isTimelineOpen() ? 'cockpit.timeline_hide' : 'cockpit.timeline_show'), () => toggleTimeline());
+ b.classList.add('ck-tl-toggle');
+ newDot(b, 'timeline');
b.classList.toggle('on', isTimelineOpen()); b.setAttribute('aria-pressed', String(isTimelineOpen()));
timeline.push(b);
}
@@ -2297,6 +2359,7 @@ function renderHeader(): void {
const st = imageStripState();
const b = actBtn('image', tr(st.open ? 'cockpit.images_hide' : 'cockpit.images_show'), () => toggleImageStrip());
b.classList.add('ck-img-toggle');
+ newDot(b, 'image-strip');
b.classList.toggle('on', st.open); b.setAttribute('aria-pressed', String(st.open));
if (st.total > 0) {
const n = document.createElement('span'); n.className = `ck-img-count${st.unseen > 0 ? ' new' : ''}`;
diff --git a/src/renderer/features/cockpit/cockpit.css b/src/renderer/features/cockpit/cockpit.css
index 971fe94..1044b37 100644
--- a/src/renderer/features/cockpit/cockpit.css
+++ b/src/renderer/features/cockpit/cockpit.css
@@ -331,7 +331,7 @@
#view-cockpit .ck-act.ck-qr-btn.ready::after {
content: "";
position: absolute;
- top: 5px;
+ bottom: 5px;
right: 5px;
width: 6px;
height: 6px;
@@ -591,3 +591,81 @@
font: inherit;
line-height: 1.5;
}
+
+/* ---- the agent's own status (Claude Code OSC 7501) in the session header ---- */
+#view-cockpit .ck-status-pill {
+ font-family: var(--dd-font-sans);
+ font-size: 12px;
+ letter-spacing: 0;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ max-width: 360px;
+ font-weight: 600;
+}
+
+#view-cockpit .ck-status-pill[hidden] {
+ display: none;
+}
+
+.ck-status-dot {
+ flex: 0 0 auto;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: currentColor;
+}
+
+.ck-status-text {
+ flex: 0 0 auto;
+}
+
+.ck-status-msg {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: 400;
+ color: var(--text-dim);
+}
+
+.ck-status-msg::before {
+ content: "· ";
+}
+
+#view-cockpit .ck-status-pill.st-working {
+ color: var(--dd-working);
+ border-color: color-mix(in srgb, var(--dd-working) 45%, transparent);
+}
+
+#view-cockpit .ck-status-pill.st-working .ck-status-dot {
+ animation: ck-status-pulse 1.4s ease-in-out infinite;
+}
+
+#view-cockpit .ck-status-pill.st-blocked {
+ color: #1a1206;
+ background: var(--dd-attention);
+ border-color: var(--dd-attention);
+}
+
+#view-cockpit .ck-status-pill.st-blocked .ck-status-dot {
+ background: #1a1206;
+}
+
+#view-cockpit .ck-status-pill.st-done {
+ color: var(--dd-success);
+ border-color: color-mix(in srgb, var(--dd-success) 45%, transparent);
+}
+
+#view-cockpit .ck-status-pill.st-error {
+ color: #ff8a7a;
+ border-color: color-mix(in srgb, #ff8a7a 45%, transparent);
+}
+
+@keyframes ck-status-pulse {
+ 50% { opacity: 0.35; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ #view-cockpit .ck-status-pill.st-working .ck-status-dot { animation: none; }
+}
diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts
index 27e812f..7778e53 100644
--- a/src/renderer/global.d.ts
+++ b/src/renderer/global.d.ts
@@ -18,7 +18,7 @@ declare global {
getAgent(): Promise;
setAgent(id: string): Promise;
availableAgents(): Promise;
- getSettings(): Promise<{ baseDir: string; thresholds: { freshDays: number; warnDays: number; neglectedDays: number }; language: string; openAtLogin: boolean; platform: string; osRelease: string; ptyAvailable: boolean; viewMode: 'cards' | 'list'; trayAlert: 'off' | 'attention' | 'all'; contextWindow: number; shutdownIdleMinutes: number; cockpitSidebarCollapsed: boolean; sessionSummary: boolean; aiSessionSummary: boolean; quickReplies: string[] | null }>;
+ getSettings(): Promise<{ baseDir: string; thresholds: { freshDays: number; warnDays: number; neglectedDays: number }; language: string; openAtLogin: boolean; platform: string; osRelease: string; ptyAvailable: boolean; viewMode: 'cards' | 'list'; trayAlert: 'off' | 'attention' | 'all'; contextWindow: number; shutdownIdleMinutes: number; cockpitSidebarCollapsed: boolean; sessionSummary: boolean; aiSessionSummary: boolean; quickReplies: string[] | null; programStatus: boolean; whatsNewSeen: string | null; usedBefore: boolean }>;
link: {
hostStatus(): Promise;
setHostMode(on: boolean): Promise;
@@ -97,6 +97,8 @@ declare global {
setSessionSummary(on: boolean): Promise;
setAiSessionSummary(on: boolean): Promise;
setQuickReplies(list: string[]): Promise;
+ setProgramStatus(on: boolean): Promise;
+ setWhatsNewSeen(version: string): Promise;
usageSnapshot(): Promise;
refreshUsageProviders(opts?: { force?: boolean }): Promise;
openUsageLogin(providerId: 'claude' | 'codex', cols: number, rows: number): Promise<{ id: string; providerId: 'claude' | 'codex' } | null>;
diff --git a/src/renderer/imageStrip.ts b/src/renderer/imageStrip.ts
index b4124ba..1830222 100644
--- a/src/renderer/imageStrip.ts
+++ b/src/renderer/imageStrip.ts
@@ -11,6 +11,7 @@ import { createIcon } from './icons';
import { toast } from './loadError';
import { fitPngBase64 } from './attachments';
import { ImageMarkup, type MarkTool } from './imageMarkup';
+import { newDot } from './whatsNew';
/** Everything the strip needs to know about one session — where its images come from. */
export interface ImageSource {
@@ -298,6 +299,7 @@ function openViewer(src: ImageSource, list: SessionImageRef[], startIndex: numbe
const clear = mk('trash', tr('cockpit.markup_clear'), () => markup.clear());
tools.append(undo, clear);
const markBtn = mk('pen', tr('cockpit.image_markup'), () => { markup.setEnabled(!markup.active); syncMarkup(); });
+ newDot(markBtn, 'image-markup');
let sending = false;
const send = mk('send', tr('cockpit.image_attach'), () => {
if (!img.src || sending || !src.attach) return;
diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json
index 50d73fc..54b2718 100644
--- a/src/renderer/locales/en.json
+++ b/src/renderer/locales/en.json
@@ -563,5 +563,54 @@
"cockpit.image_attach_failed": "Could not attach the image — nothing was typed into the session.",
"set.quick_replies": "Quick replies",
"set.quick_replies_hint": "One per line, up to 9. Sent with the 💬 button in a session's header, or Ctrl+Shift+1…9 in its terminal. Leave empty for the defaults.",
- "cockpit.timeline_host_too_old": "The DevDeck on that PC is too old to show a timeline — update it to 1.41.0."
+ "cockpit.timeline_host_too_old": "The DevDeck on that PC is too old to show a timeline — update it to 1.41.0.",
+ "cockpit.status_working": "Working",
+ "cockpit.status_done": "Done",
+ "cockpit.status_error": "Stopped on an error",
+ "cockpit.status_blocked_permission": "Needs your approval",
+ "cockpit.status_blocked_question": "Asking you a question",
+ "cockpit.status_blocked_auth": "Needs sign-in",
+ "cockpit.status_blocked_other": "Waiting for you",
+ "cockpit.status_source": "Reported by Claude Code itself",
+ "cockpit.notify_blocked_permission": "Claude is waiting for your approval.",
+ "cockpit.notify_blocked_question": "Claude asked you a question.",
+ "cockpit.notify_blocked_auth": "Claude needs you to sign in again.",
+ "set.program_status": "Claude status signal (experimental)",
+ "set.program_status_hint": "Claude Code tells DevDeck itself whether it is working, needs approval or is asking a question, instead of DevDeck reading the screen. Sessions started after turning this on run on the console engine bundled with DevDeck (Windows Terminal's).",
+ "whatsnew.title": "What's new in DevDeck {version}",
+ "whatsnew.subtitle": "Recent changes, and where to find each one.",
+ "whatsnew.close": "Close",
+ "whatsnew.ok": "Got it",
+ "whatsnew.all_notes": "All release notes",
+ "whatsnew.show_me": "Show me",
+ "whatsnew.open": "What's new",
+ "whatsnew.need_session": "Open a Claude session first — this lives in a session's header.",
+ "whatsnew.not_found": "That setting is not available on this computer.",
+ "whatsnew.f.program-status.title": "Claude tells DevDeck what it is doing",
+ "whatsnew.f.program-status.body": "Working, done, needs approval or asking a question — straight from Claude Code instead of guessed from the screen. The session header shows it, with what it just did.",
+ "whatsnew.f.program-status.where": "Settings → Claude status signal (experimental), then start a new session",
+ "whatsnew.f.quick-replies.title": "Quick replies",
+ "whatsnew.f.quick-replies.body": "Send \"Yes, go ahead\", \"Commit it\" and your own phrases in one click, or with Ctrl+Shift+1…9.",
+ "whatsnew.f.quick-replies.where": "The 💬 button in a session's header",
+ "whatsnew.f.timeline.title": "Session timeline",
+ "whatsnew.f.timeline.body": "Every prompt you sent, with its time, the reply and its screenshots. Click one to jump there.",
+ "whatsnew.f.timeline.where": "The timeline button in a session's header",
+ "whatsnew.f.image-markup.title": "Mark up a screenshot and send it back",
+ "whatsnew.f.image-markup.body": "Draw red boxes and arrows on a screenshot, then attach it to the session in one click.",
+ "whatsnew.f.image-markup.where": "Images → open one → ✏ Mark up",
+ "whatsnew.f.remote-documents.title": "Documents for a session on another PC",
+ "whatsnew.f.remote-documents.body": "Text, log, data and PDF files now travel too, not only images.",
+ "whatsnew.f.remote-documents.where": "📎 or drag and drop onto a remote session",
+ "whatsnew.f.image-rows.title": "Which step was this screenshot?",
+ "whatsnew.f.image-rows.body": "[Image] rows in the terminal are highlighted: hover for a thumbnail, click to open. The viewer shows what led to each image and can jump back to its row, or copy it.",
+ "whatsnew.f.image-rows.where": "Highlighted [Image] rows, and the image viewer",
+ "whatsnew.f.prompt-jump.title": "Jump between your prompts",
+ "whatsnew.f.prompt-jump.body": "Step back and forward through the prompts you sent in a long session.",
+ "whatsnew.f.prompt-jump.where": "Ctrl+↑ / Ctrl+↓ in a terminal",
+ "whatsnew.f.image-strip.title": "Screenshots beside the terminal",
+ "whatsnew.f.image-strip.body": "The screenshots and images a Claude session worked with, as thumbnails — also for sessions on another PC.",
+ "whatsnew.f.image-strip.where": "The image button in a session's header",
+ "whatsnew.f.update-all.title": "Update all Claude sessions at once",
+ "whatsnew.f.update-all.body": "When Claude Code has an update, one button stops every session, updates it and brings the same conversations back.",
+ "whatsnew.f.update-all.where": "Appears in the session header when an update is waiting"
}
diff --git a/src/renderer/locales/ja.json b/src/renderer/locales/ja.json
index 4c20779..b1cbdae 100644
--- a/src/renderer/locales/ja.json
+++ b/src/renderer/locales/ja.json
@@ -563,5 +563,54 @@
"cockpit.image_attach_failed": "画像を添付できませんでした — セッションには何も入力していません。",
"set.quick_replies": "クイック返信",
"set.quick_replies_hint": "1 行に 1 つ、最大 9 個。セッション上部の 💬 ボタン、またはターミナルで Ctrl+Shift+1…9 で送信します。空欄なら既定の文言を使います。",
- "cockpit.timeline_host_too_old": "その PC の DevDeck はタイムラインに対応していないバージョンです — 1.41.0 に更新してください。"
+ "cockpit.timeline_host_too_old": "その PC の DevDeck はタイムラインに対応していないバージョンです — 1.41.0 に更新してください。",
+ "cockpit.status_working": "作業中",
+ "cockpit.status_done": "完了",
+ "cockpit.status_error": "エラーで停止",
+ "cockpit.status_blocked_permission": "承認が必要",
+ "cockpit.status_blocked_question": "質問への回答が必要",
+ "cockpit.status_blocked_auth": "ログインが必要",
+ "cockpit.status_blocked_other": "入力待ち",
+ "cockpit.status_source": "Claude Code 自身が知らせた状態",
+ "cockpit.notify_blocked_permission": "Claude が承認を待っています。",
+ "cockpit.notify_blocked_question": "Claude が質問しています。",
+ "cockpit.notify_blocked_auth": "Claude に再ログインが必要です。",
+ "set.program_status": "Claude ステータス信号 (実験的)",
+ "set.program_status_hint": "DevDeck が画面から推測する代わりに、Claude Code が作業中・承認待ち・質問中かを直接知らせます。オンにした後に開始するセッションから、DevDeck 同梱のコンソールエンジン (Windows Terminal と同じもの) で実行されます。",
+ "whatsnew.title": "DevDeck {version} の新機能",
+ "whatsnew.subtitle": "最近の変更と、それぞれの場所です。",
+ "whatsnew.close": "閉じる",
+ "whatsnew.ok": "OK",
+ "whatsnew.all_notes": "すべてのリリースノート",
+ "whatsnew.show_me": "場所を見る",
+ "whatsnew.open": "新機能を見る",
+ "whatsnew.need_session": "先に Claude セッションを開いてください — セッションのヘッダーにある機能です。",
+ "whatsnew.not_found": "この設定はこのコンピューターでは使えません。",
+ "whatsnew.f.program-status.title": "Claude が自分で状態を知らせます",
+ "whatsnew.f.program-status.body": "作業中・完了・承認待ち・質問中を、画面からの推測ではなく Claude Code から直接受け取ります。セッションのヘッダーに、直前にしたことと一緒に表示されます。",
+ "whatsnew.f.program-status.where": "設定 → Claude ステータス信号 (実験的) をオンにして新しいセッションを開始",
+ "whatsnew.f.quick-replies.title": "クイック返信",
+ "whatsnew.f.quick-replies.body": "「はい、続けてください」「コミットしてください」や自分の文言をワンクリックで送ります。Ctrl+Shift+1〜9 でも送れます。",
+ "whatsnew.f.quick-replies.where": "セッションヘッダーの 💬 ボタン",
+ "whatsnew.f.timeline.title": "セッションのタイムライン",
+ "whatsnew.f.timeline.body": "送ったプロンプトを時刻・返答・スクリーンショットと一緒に一覧にします。クリックでその位置へ移動します。",
+ "whatsnew.f.timeline.where": "セッションヘッダーのタイムラインボタン",
+ "whatsnew.f.image-markup.title": "スクリーンショットに印を付けて送り返す",
+ "whatsnew.f.image-markup.body": "スクリーンショットに赤い四角や矢印を描き、ワンクリックでセッションに添付します。",
+ "whatsnew.f.image-markup.where": "画像 → 1 枚開く → ✏ マークアップ",
+ "whatsnew.f.remote-documents.title": "他の PC のセッションにドキュメントを添付",
+ "whatsnew.f.remote-documents.body": "画像だけでなく、テキスト・ログ・データ・PDF ファイルも送れます。",
+ "whatsnew.f.remote-documents.where": "リモートセッションで 📎 またはドラッグ&ドロップ",
+ "whatsnew.f.image-rows.title": "このスクリーンショットはどの段階?",
+ "whatsnew.f.image-rows.body": "ターミナルの [Image] 行が強調され、ポインターを置くとサムネイル、クリックで拡大表示します。ビューアーでその画像の状況を確認し、その行へ移動したりコピーしたりできます。",
+ "whatsnew.f.image-rows.where": "強調された [Image] 行と画像ビューアー",
+ "whatsnew.f.prompt-jump.title": "自分のプロンプト間を移動",
+ "whatsnew.f.prompt-jump.body": "長いセッションで、送ったプロンプトの位置へ前後に移動します。",
+ "whatsnew.f.prompt-jump.where": "ターミナルで Ctrl+↑ / Ctrl+↓",
+ "whatsnew.f.image-strip.title": "ターミナル横のスクリーンショット",
+ "whatsnew.f.image-strip.body": "Claude セッションが扱ったスクリーンショットや画像をサムネイルで表示します。他の PC のセッションも対象です。",
+ "whatsnew.f.image-strip.where": "セッションヘッダーの画像ボタン",
+ "whatsnew.f.update-all.title": "Claude セッションをまとめて更新",
+ "whatsnew.f.update-all.body": "Claude Code の更新があると、ボタン 1 つで全セッションを止めて更新し、同じ会話を開き直します。",
+ "whatsnew.f.update-all.where": "更新待ちのときにセッションヘッダーに表示されます"
}
diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json
index 0f15746..96ebe2c 100644
--- a/src/renderer/locales/ko.json
+++ b/src/renderer/locales/ko.json
@@ -563,5 +563,54 @@
"cockpit.image_attach_failed": "이미지를 첨부하지 못했습니다 — 세션에는 아무것도 입력하지 않았습니다.",
"set.quick_replies": "빠른 답장",
"set.quick_replies_hint": "한 줄에 하나씩, 최대 9개. 세션 상단의 💬 버튼이나 터미널에서 Ctrl+Shift+1…9로 보냅니다. 비워 두면 기본 문구를 씁니다.",
- "cockpit.timeline_host_too_old": "그 PC의 DevDeck이 타임라인을 지원하지 않는 버전입니다 — 1.41.0으로 업데이트하세요."
+ "cockpit.timeline_host_too_old": "그 PC의 DevDeck이 타임라인을 지원하지 않는 버전입니다 — 1.41.0으로 업데이트하세요.",
+ "cockpit.status_working": "작업 중",
+ "cockpit.status_done": "완료",
+ "cockpit.status_error": "오류로 멈춤",
+ "cockpit.status_blocked_permission": "승인 필요",
+ "cockpit.status_blocked_question": "질문에 답변 필요",
+ "cockpit.status_blocked_auth": "로그인 필요",
+ "cockpit.status_blocked_other": "입력 대기",
+ "cockpit.status_source": "Claude Code가 직접 알려 준 상태",
+ "cockpit.notify_blocked_permission": "Claude가 승인을 기다리고 있습니다.",
+ "cockpit.notify_blocked_question": "Claude가 질문을 했습니다.",
+ "cockpit.notify_blocked_auth": "Claude에 다시 로그인해야 합니다.",
+ "set.program_status": "Claude 상태 신호 (실험)",
+ "set.program_status_hint": "DevDeck이 화면을 읽어 추측하는 대신, Claude Code가 작업 중·승인 필요·질문 중인지 직접 알려 줍니다. 켠 뒤 새로 시작하는 세션부터 DevDeck에 포함된 콘솔 엔진(Windows Terminal과 같은 것)으로 실행됩니다.",
+ "whatsnew.title": "DevDeck {version}의 새로운 기능",
+ "whatsnew.subtitle": "최근 바뀐 점과 각각 어디에서 보는지 정리했습니다.",
+ "whatsnew.close": "닫기",
+ "whatsnew.ok": "확인",
+ "whatsnew.all_notes": "전체 릴리스 노트",
+ "whatsnew.show_me": "위치 보기",
+ "whatsnew.open": "새로운 기능 보기",
+ "whatsnew.need_session": "먼저 Claude 세션을 하나 열어 주세요 — 세션 상단에 있는 기능입니다.",
+ "whatsnew.not_found": "이 컴퓨터에서는 해당 설정을 사용할 수 없습니다.",
+ "whatsnew.f.program-status.title": "Claude가 직접 상태를 알려 줍니다",
+ "whatsnew.f.program-status.body": "작업 중·완료·승인 필요·질문 중을 화면 추측이 아니라 Claude Code에게서 직접 받습니다. 세션 상단에 방금 한 일과 함께 표시됩니다.",
+ "whatsnew.f.program-status.where": "설정 → Claude 상태 신호 (실험)을 켠 뒤 새 세션 시작",
+ "whatsnew.f.quick-replies.title": "빠른 답장",
+ "whatsnew.f.quick-replies.body": "\"네 계속 진행해줘\", \"커밋해줘\"나 직접 정한 문구를 한 번에 보냅니다. Ctrl+Shift+1~9로도 됩니다.",
+ "whatsnew.f.quick-replies.where": "세션 상단의 💬 버튼",
+ "whatsnew.f.timeline.title": "세션 타임라인",
+ "whatsnew.f.timeline.body": "내가 보낸 프롬프트를 시각·답변·스크린샷과 함께 모아 보여 줍니다. 클릭하면 그 위치로 이동합니다.",
+ "whatsnew.f.timeline.where": "세션 상단의 타임라인 버튼",
+ "whatsnew.f.image-markup.title": "스크린샷에 표시해서 다시 보내기",
+ "whatsnew.f.image-markup.body": "스크린샷에 빨간 박스와 화살표를 그리고 한 번에 세션에 첨부합니다.",
+ "whatsnew.f.image-markup.where": "이미지 → 하나 열기 → ✏ 표시하기",
+ "whatsnew.f.remote-documents.title": "다른 PC 세션에 문서 첨부",
+ "whatsnew.f.remote-documents.body": "이미지뿐 아니라 텍스트·로그·데이터·PDF 파일도 보낼 수 있습니다.",
+ "whatsnew.f.remote-documents.where": "원격 세션에서 📎 또는 끌어다 놓기",
+ "whatsnew.f.image-rows.title": "이 스크린샷이 어느 단계였는지",
+ "whatsnew.f.image-rows.body": "터미널의 [Image] 줄이 강조되고, 마우스를 올리면 썸네일, 클릭하면 크게 열립니다. 뷰어에서 그 이미지가 나온 상황을 보고, 그 줄로 이동하거나 복사할 수 있습니다.",
+ "whatsnew.f.image-rows.where": "강조된 [Image] 줄과 이미지 뷰어",
+ "whatsnew.f.prompt-jump.title": "내 프롬프트 사이 이동",
+ "whatsnew.f.prompt-jump.body": "긴 세션에서 내가 보낸 프롬프트 위치로 앞뒤로 건너뜁니다.",
+ "whatsnew.f.prompt-jump.where": "터미널에서 Ctrl+↑ / Ctrl+↓",
+ "whatsnew.f.image-strip.title": "터미널 옆 스크린샷",
+ "whatsnew.f.image-strip.body": "Claude 세션이 다룬 스크린샷과 이미지를 썸네일로 보여 줍니다. 다른 PC의 세션도 됩니다.",
+ "whatsnew.f.image-strip.where": "세션 상단의 이미지 버튼",
+ "whatsnew.f.update-all.title": "Claude 세션 한 번에 업데이트",
+ "whatsnew.f.update-all.body": "Claude Code 업데이트가 있으면 버튼 하나로 모든 세션을 멈추고 업데이트한 뒤 같은 대화로 다시 엽니다.",
+ "whatsnew.f.update-all.where": "업데이트가 대기 중일 때 세션 상단에 나타납니다"
}
diff --git a/src/renderer/locales/zh.json b/src/renderer/locales/zh.json
index 2ceec0d..a0baca2 100644
--- a/src/renderer/locales/zh.json
+++ b/src/renderer/locales/zh.json
@@ -563,5 +563,54 @@
"cockpit.image_attach_failed": "无法附加图片 — 没有向会话输入任何内容。",
"set.quick_replies": "快捷回复",
"set.quick_replies_hint": "每行一条,最多 9 条。用会话顶部的 💬 按钮或在终端中按 Ctrl+Shift+1…9 发送。留空则使用默认文案。",
- "cockpit.timeline_host_too_old": "该电脑上的 DevDeck 版本过旧,无法显示时间线 — 请更新到 1.41.0。"
+ "cockpit.timeline_host_too_old": "该电脑上的 DevDeck 版本过旧,无法显示时间线 — 请更新到 1.41.0。",
+ "cockpit.status_working": "工作中",
+ "cockpit.status_done": "已完成",
+ "cockpit.status_error": "因错误停止",
+ "cockpit.status_blocked_permission": "需要你批准",
+ "cockpit.status_blocked_question": "需要回答问题",
+ "cockpit.status_blocked_auth": "需要登录",
+ "cockpit.status_blocked_other": "等待输入",
+ "cockpit.status_source": "由 Claude Code 直接报告的状态",
+ "cockpit.notify_blocked_permission": "Claude 正在等待你的批准。",
+ "cockpit.notify_blocked_question": "Claude 向你提了一个问题。",
+ "cockpit.notify_blocked_auth": "Claude 需要你重新登录。",
+ "set.program_status": "Claude 状态信号 (实验)",
+ "set.program_status_hint": "由 Claude Code 直接告诉 DevDeck 它是否在工作、需要批准或正在提问,而不是由 DevDeck 读屏猜测。开启后新启动的会话将使用 DevDeck 自带的控制台引擎 (与 Windows Terminal 相同)。",
+ "whatsnew.title": "DevDeck {version} 新功能",
+ "whatsnew.subtitle": "最近的变化,以及各自在哪里。",
+ "whatsnew.close": "关闭",
+ "whatsnew.ok": "知道了",
+ "whatsnew.all_notes": "全部发行说明",
+ "whatsnew.show_me": "查看位置",
+ "whatsnew.open": "查看新功能",
+ "whatsnew.need_session": "请先打开一个 Claude 会话 — 该功能位于会话顶部。",
+ "whatsnew.not_found": "此电脑上无法使用该设置。",
+ "whatsnew.f.program-status.title": "Claude 直接告诉你它在做什么",
+ "whatsnew.f.program-status.body": "工作中、已完成、需要批准、正在提问 — 直接来自 Claude Code,而不是读屏猜测。会话顶部会显示状态及刚完成的事。",
+ "whatsnew.f.program-status.where": "设置 → Claude 状态信号 (实验),然后启动新会话",
+ "whatsnew.f.quick-replies.title": "快捷回复",
+ "whatsnew.f.quick-replies.body": "一键发送“好的,继续”“提交一下”或你自己的文案,也可以用 Ctrl+Shift+1…9。",
+ "whatsnew.f.quick-replies.where": "会话顶部的 💬 按钮",
+ "whatsnew.f.timeline.title": "会话时间线",
+ "whatsnew.f.timeline.body": "列出你发送的每条提示及其时间、回复和截图。点击即可跳到那里。",
+ "whatsnew.f.timeline.where": "会话顶部的时间线按钮",
+ "whatsnew.f.image-markup.title": "在截图上标注并发回",
+ "whatsnew.f.image-markup.body": "在截图上画红框和箭头,一键附加到会话。",
+ "whatsnew.f.image-markup.where": "图片 → 打开一张 → ✏ 标注",
+ "whatsnew.f.remote-documents.title": "给另一台电脑上的会话附加文档",
+ "whatsnew.f.remote-documents.body": "除了图片,文本、日志、数据和 PDF 文件也能发送。",
+ "whatsnew.f.remote-documents.where": "在远程会话中使用 📎 或拖放",
+ "whatsnew.f.image-rows.title": "这张截图是哪一步?",
+ "whatsnew.f.image-rows.body": "终端中的 [Image] 行会高亮:悬停查看缩略图,点击打开。查看器会显示图片的来龙去脉,并可跳回该行或复制图片。",
+ "whatsnew.f.image-rows.where": "高亮的 [Image] 行和图片查看器",
+ "whatsnew.f.prompt-jump.title": "在你的提示之间跳转",
+ "whatsnew.f.prompt-jump.body": "在很长的会话中前后跳到你发送过的提示。",
+ "whatsnew.f.prompt-jump.where": "在终端中按 Ctrl+↑ / Ctrl+↓",
+ "whatsnew.f.image-strip.title": "终端旁的截图",
+ "whatsnew.f.image-strip.body": "以缩略图显示 Claude 会话处理过的截图和图片,另一台电脑上的会话也可以。",
+ "whatsnew.f.image-strip.where": "会话顶部的图片按钮",
+ "whatsnew.f.update-all.title": "一次更新所有 Claude 会话",
+ "whatsnew.f.update-all.body": "Claude Code 有更新时,一个按钮即可停止所有会话、完成更新并重新打开相同的对话。",
+ "whatsnew.f.update-all.where": "有更新等待时出现在会话顶部"
}
diff --git a/src/renderer/main.ts b/src/renderer/main.ts
index e9ee52d..8e946d2 100644
--- a/src/renderer/main.ts
+++ b/src/renderer/main.ts
@@ -8,6 +8,7 @@ import { mountUsage, showUsage } from './usageView';
import { mountSettings, showSettings } from './settingsView';
import { mountNext, showNext } from './nextView';
import { activateCockpitSession, closeCockpitSessionGroup, closeCockpitSessions, cockpitNavigationAliases, cockpitNavigationItems, manageCockpitSessionAction, mountCockpit, onCockpitNavigationChange, onCockpitSessionsLoaded, onCockpitSessionSelected, restoreAllCockpitSessions, setCockpitNavigationCallback, showCockpit, liveSessionCount, runningSessionsForAutoRestore, refreshLiveSessionIds, setCockpitContextWindow, setCockpitTrayAlert, setCockpitSidebarCollapsed, refreshCockpitSidebar, setCockpitSessionSummary, setCockpitAiSummary, setCockpitQuickReplies } from './cockpitView';
+import { openWhatsNew, setFeatureNavigator, whatsNewOnStart } from './whatsNew';
import { isCockpitAvailable } from '../shared/cockpitModel';
import { setLanguage, tr, currentLang, languageName, SUPPORTED } from './i18n-runtime';
import { toast } from './loadError';
@@ -288,6 +289,15 @@ async function boot(): Promise {
contextRestore?.cancel();
if (view !== 'cockpit') localStorage.setItem(SHELL_CONTEXT_KEY, JSON.stringify({ kind: 'view', id: view }));
});
+ // What changed since the last version the person ran: shown once after an update, never on a
+ // first run, and from Settings → About at any time.
+ setFeatureNavigator((view) => nav.show(view));
+ void window.devdeck.getAppInfo().then((info) => {
+ const plan = whatsNewOnStart(info.version, settings.whatsNewSeen, settings.usedBefore);
+ const markSeen = (): void => { void window.devdeck.setWhatsNewSeen(info.version); };
+ if (plan.show) setTimeout(() => openWhatsNew({ version: info.version, after: plan.after, onClose: markSeen }), 1500);
+ else if (settings.whatsNewSeen !== info.version) markSeen();
+ }).catch(() => { /* no version, no news */ });
shellController = mountShell({
initialCollapsed: settings.cockpitSidebarCollapsed,
showView: nav.show,
diff --git a/src/renderer/settingsView.ts b/src/renderer/settingsView.ts
index d2a058d..8fa6427 100644
--- a/src/renderer/settingsView.ts
+++ b/src/renderer/settingsView.ts
@@ -2,6 +2,7 @@ import { tr, SUPPORTED, languageName, setLanguage as setRuntimeLang } from './i1
import { setCockpitContextWindow, setCockpitTrayAlert, setCockpitSessionSummary, setCockpitAiSummary, setCockpitQuickReplies } from './cockpitView';
import { quickReplies } from './quickReplyMenu';
import { parseQuickReplyText } from '../shared/quickReplies';
+import { markFeatureSeen, newDot, openWhatsNew } from './whatsNew';
import type { Folder } from '../shared/types';
import { IDLE_HOLD_CHOICES } from '../shared/shutdownIdle';
import { basename, parentLabel } from '../shared/paths';
@@ -120,6 +121,17 @@ async function render(): Promise {
const aiWrap = document.createElement('div'); aiWrap.className = 'set-inline';
const aiHint = document.createElement('span'); aiHint.className = 'set-hint'; aiHint.textContent = tr('set.ai_summary_hint');
aiWrap.append(ai, aiHint); content.appendChild(field('set.ai_summary', aiWrap, ai));
+ // Claude Code's own status report. Opt-in on Windows: it runs new sessions on the ConPTY bundled
+ // with DevDeck instead of the one built into Windows.
+ const ps = document.createElement('input'); ps.type = 'checkbox'; ps.className = 'set-check'; ps.id = 'set-program-status'; ps.checked = s.programStatus;
+ bindCheck(ps, v => window.devdeck.setProgramStatus(v));
+ ps.addEventListener('change', () => markFeatureSeen('program-status'));
+ const psWrap = document.createElement('div'); psWrap.className = 'set-inline';
+ const psHint = document.createElement('span'); psHint.className = 'set-hint'; psHint.textContent = tr('set.program_status_hint');
+ psWrap.append(ps, psHint);
+ const psRow = field('set.program_status', psWrap, ps);
+ newDot(psRow.querySelector('.set-label')!, 'program-status');
+ content.appendChild(psRow);
const hold = document.createElement('select'); hold.className = 'set-input';
for (const m of IDLE_HOLD_CHOICES) {
const o = document.createElement('option'); o.value = String(m); o.textContent = String(m); o.selected = m === s.shutdownIdleMinutes; hold.appendChild(o);
@@ -182,6 +194,9 @@ async function render(): Promise {
const b = document.createElement('button'); b.className = 'chip'; b.textContent = tr(labelKey);
b.addEventListener('click', () => void window.devdeck.openExternal(url)); return b;
};
+ const news = document.createElement('button'); news.className = 'chip wn-primary about-whatsnew'; news.textContent = tr('whatsnew.open');
+ news.addEventListener('click', () => openWhatsNew({ version: info.version, after: null }));
+ links.append(news);
links.append(link('about.github', info.repoUrl), link('about.releases', info.repoUrl + '/releases/latest'), link('about.license', info.repoUrl + '/blob/main/LICENSE'), link('about.report_issue', info.repoUrl + '/issues'));
const upd = document.createElement('div'); upd.className = 'about-upd';
const check = document.createElement('button'); check.className = 'chip'; check.textContent = tr('about.check_updates');
diff --git a/src/renderer/styles.css b/src/renderer/styles.css
index 24b5d05..916c109 100644
--- a/src/renderer/styles.css
+++ b/src/renderer/styles.css
@@ -808,3 +808,41 @@ button.ub-stale:focus-visible { outline: 2px solid var(--dd-focus); outline-offs
.shutdown-hist-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 6px; }
.shutdown-hist-count { color: var(--text-faint); font-size: 12px; }
.shutdown-hist-empty { color: #a5a5a5; font-style: italic; } /* same tone as rows — #6b7690 failed axe color-contrast (serious) */
+
+/* ---- What's new: NEW dots, the spotlight ring, the window ---- */
+.dd-has-new { position: relative; }
+.dd-new-dot {
+ position: absolute; top: 3px; left: 3px; width: 8px; height: 8px; border-radius: 50%;
+ background: #ff9f0a; box-shadow: 0 0 0 2px var(--dd-canvas); pointer-events: none;
+}
+.set-label.dd-has-new { padding-left: 14px; }
+.set-label > .dd-new-dot { top: 50%; transform: translateY(-50%); }
+.dd-spotlight {
+ outline: 2px solid #ff9f0a; outline-offset: 3px; border-radius: 8px;
+ animation: dd-spotlight 1s ease-in-out 3;
+}
+@keyframes dd-spotlight { 50% { outline-color: transparent; outline-offset: 7px; } }
+@media (prefers-reduced-motion: reduce) { .dd-spotlight { animation: none; } }
+
+.wn-overlay { position: fixed; inset: 0; z-index: 96; display: flex; align-items: center; justify-content: center; padding: 24px; background: rgb(0 0 0 / .6); }
+.wn-dialog { width: min(640px, 100%); max-height: calc(100vh - 48px); display: flex; flex-direction: column; overflow: hidden; border-radius: var(--dd-radius-lg); box-shadow: 0 20px 60px rgb(0 0 0 / .6); }
+.wn-head, .wn-foot { display: flex; align-items: flex-start; gap: 10px; padding: 16px 18px; flex: 0 0 auto; }
+.wn-head { border-bottom: 1px solid var(--border); }
+.wn-heading { flex: 1; min-width: 0; }
+.wn-heading h2 { margin: 0; font-size: var(--fs-view); }
+.wn-sub { margin: 4px 0 0; color: var(--text-dim); font-size: var(--fs-body); }
+.wn-close { display: inline-grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 0; border-radius: 6px; background: transparent; color: var(--text-dim); cursor: pointer; }
+.wn-close:hover { color: var(--text); background: var(--surface-hover); }
+.wn-body { flex: 1; min-height: 0; overflow-y: auto; padding: 4px 18px 12px; }
+.wn-version { margin: 14px 0 6px; font-size: var(--fs-meta); font-weight: 600; letter-spacing: .04em; color: var(--text-faint); }
+.wn-card { display: flex; align-items: flex-start; gap: 12px; padding: 12px; margin-bottom: 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); }
+.wn-icon { flex: 0 0 auto; display: inline-grid; place-items: center; width: 34px; height: 34px; border-radius: 8px; background: color-mix(in srgb, #ff9f0a 16%, transparent); color: #ffb340; }
+.wn-icon svg { width: 18px; height: 18px; }
+.wn-text { flex: 1; min-width: 0; }
+.wn-text h4 { margin: 0 0 3px; font-size: var(--fs-row); color: var(--text); }
+.wn-text p { margin: 0; font-size: var(--fs-body); line-height: 1.5; color: var(--text-dim); }
+.wn-text .wn-where { margin-top: 5px; font-size: var(--fs-meta); color: var(--text-faint); }
+.wn-show { flex: 0 0 auto; align-self: center; }
+.wn-foot { justify-content: flex-end; align-items: center; border-top: 1px solid var(--border); }
+.chip.wn-primary { background: #ff9f0a; border-color: #ff9f0a; color: #1a1206; font-weight: 600; }
+.chip.wn-primary:hover { background: #ffb340; }
diff --git a/src/renderer/whatsNew.test.ts b/src/renderer/whatsNew.test.ts
new file mode 100644
index 0000000..599fd0b
--- /dev/null
+++ b/src/renderer/whatsNew.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest';
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { compareVersions, FEATURES, featuresAfter, whatsNewOnStart } from './whatsNew';
+
+describe('when the What\'s new window opens', () => {
+ it('opens once after an update, never on a first run, and not again for the same version', () => {
+ expect(whatsNewOnStart('1.42.0', '1.41.0', true)).toEqual({ show: true, after: '1.41.0' });
+ expect(whatsNewOnStart('1.42.0', '1.42.0', true)).toEqual({ show: false, after: '1.42.0' });
+ // Before 1.42.0 nothing was recorded: an install that was used before is an upgrade.
+ expect(whatsNewOnStart('1.42.0', null, true)).toEqual({ show: true, after: null });
+ expect(whatsNewOnStart('1.42.0', null, false)).toEqual({ show: false, after: null });
+ });
+
+ it('lists only what is newer than what was last shown, newest first', () => {
+ expect(featuresAfter('1.41.0').map((f) => f.version)).toEqual(['1.42.0']);
+ const all = featuresAfter(null).map((f) => f.version);
+ expect(all[0]).toBe('1.42.0');
+ expect([...all].sort((a, b) => compareVersions(b, a))).toEqual(all);
+ });
+
+ it('compares versions numerically', () => {
+ expect(compareVersions('1.10.0', '1.9.0')).toBeGreaterThan(0);
+ expect(compareVersions('1.41.0', '1.41.0')).toBe(0);
+ });
+
+ it('has every feature described in every language', () => {
+ for (const lang of ['en', 'ko', 'ja', 'zh']) {
+ const dict = JSON.parse(readFileSync(join(__dirname, 'locales', `${lang}.json`), 'utf8')) as Record;
+ for (const f of FEATURES) for (const part of ['title', 'body', 'where']) {
+ expect(dict[`whatsnew.f.${f.id}.${part}`], `${lang} ${f.id}.${part}`).toBeTruthy();
+ }
+ }
+ });
+});
diff --git a/src/renderer/whatsNew.ts b/src/renderer/whatsNew.ts
new file mode 100644
index 0000000..131a290
--- /dev/null
+++ b/src/renderer/whatsNew.ts
@@ -0,0 +1,202 @@
+// src/renderer/whatsNew.ts
+// Making new features visible: a "What's new" window after an update, a NEW dot on each new button
+// until it is first used, and a "Show me" that takes the person to the feature and rings it.
+//
+// Features shipped and nobody noticed: each was one more icon in a header that already had ten. The
+// dialog says what changed and where; the dot stays on the button itself until it has been tried.
+import { tr } from './i18n-runtime';
+import { createIcon, type IconName } from './icons';
+import { toast } from './loadError';
+
+export type FeatureView = 'cockpit' | 'settings';
+
+export interface Feature {
+ id: string;
+ /** The release that brought it. */
+ version: string;
+ icon: IconName;
+ /**
+ * Where it lives on screen, for "Show me"; absent for a keyboard shortcut or a background change.
+ * Its words are the i18n keys `whatsnew.f..title`, `.body` and `.where`.
+ */
+ target?: { view: FeatureView; selector: string };
+}
+
+/** Newest first. A feature's NEW dot is shown on its `target` until the person uses it. */
+export const FEATURES: readonly Feature[] = [
+ { id: 'program-status', version: '1.42.0', icon: 'terminal', target: { view: 'settings', selector: '#set-program-status' } },
+ { id: 'quick-replies', version: '1.41.0', icon: 'reply', target: { view: 'cockpit', selector: '#ck-header .ck-qr-btn' } },
+ { id: 'timeline', version: '1.41.0', icon: 'timeline', target: { view: 'cockpit', selector: '#ck-header .ck-tl-toggle' } },
+ { id: 'image-markup', version: '1.41.0', icon: 'pen', target: { view: 'cockpit', selector: '#ck-header .ck-img-toggle' } },
+ { id: 'remote-documents', version: '1.41.0', icon: 'paperclip', target: { view: 'cockpit', selector: '#ck-header .ck-attach-btn' } },
+ { id: 'image-rows', version: '1.40.0', icon: 'image', target: { view: 'cockpit', selector: '#ck-header .ck-img-toggle' } },
+ { id: 'prompt-jump', version: '1.40.0', icon: 'chevron-down' },
+ { id: 'image-strip', version: '1.39.0', icon: 'image', target: { view: 'cockpit', selector: '#ck-header .ck-img-toggle' } },
+ { id: 'update-all', version: '1.39.0', icon: 'restart' },
+];
+
+/** The oldest release the window ever lists — what came before is history, not news. */
+export const FIRST_LISTED = '1.39.0';
+const SEEN_KEY = 'devdeck.features.seen';
+const SPOTLIGHT_MS = 4000;
+
+export function compareVersions(a: string, b: string): number {
+ const pa = a.split('.').map(Number), pb = b.split('.').map(Number);
+ for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0);
+ return 0;
+}
+
+/**
+ * Whether to open the window at start-up, and from which release on. `seen` is the version it was
+ * last shown for (null before 1.42.0 kept one); `usedBefore` tells an upgrade from a first run, which
+ * has nothing to compare against and gets no window.
+ */
+export function whatsNewOnStart(current: string, seen: string | null, usedBefore: boolean): { show: boolean; after: string | null } {
+ if (seen) return { show: compareVersions(seen, current) < 0, after: seen };
+ return { show: usedBefore, after: null };
+}
+
+/** The features newer than `after` (all of them from FIRST_LISTED when null), newest first. */
+export function featuresAfter(after: string | null): Feature[] {
+ return FEATURES.filter((f) => compareVersions(f.version, FIRST_LISTED) >= 0 && (after === null || compareVersions(f.version, after) > 0));
+}
+
+// ---- NEW dots ----
+function readSeen(): Set {
+ try { return new Set(JSON.parse(localStorage.getItem(SEEN_KEY) ?? '[]') as string[]); } catch { return new Set(); }
+}
+
+/** Put a NEW dot on `el` for a feature not yet used, and clear it on first click. Safe to call on every render. */
+export function newDot(el: HTMLElement, id: string): void {
+ el.dataset.feature = id;
+ if (readSeen().has(id)) return;
+ el.classList.add('dd-has-new');
+ if (!el.querySelector(':scope > .dd-new-dot')) {
+ const dot = document.createElement('span'); dot.className = 'dd-new-dot'; dot.setAttribute('aria-hidden', 'true');
+ el.appendChild(dot);
+ }
+ el.addEventListener('click', () => markFeatureSeen(id), { once: true });
+}
+
+/** The feature has been used: its dots go, everywhere. */
+export function markFeatureSeen(id: string): void {
+ const seen = readSeen();
+ if (!seen.has(id)) {
+ seen.add(id);
+ try { localStorage.setItem(SEEN_KEY, JSON.stringify([...seen])); } catch { /* the dot simply comes back */ }
+ }
+ for (const el of document.querySelectorAll(`[data-feature="${id}"]`)) {
+ el.classList.remove('dd-has-new');
+ el.querySelector(':scope > .dd-new-dot')?.remove();
+ }
+}
+
+// ---- the window ----
+let overlay: HTMLElement | null = null;
+
+/**
+ * List what is new since `after` (everything listed when null). `onClose` runs when the window is
+ * dismissed — the caller records that this release's news has been seen.
+ */
+let navigate: (view: FeatureView) => void = () => {};
+/** How "Show me" switches views; set once by the app shell. */
+export function setFeatureNavigator(fn: (view: FeatureView) => void): void { navigate = fn; }
+
+export function openWhatsNew(opts: { version: string; after: string | null; onClose?: () => void }): void {
+ if (overlay) return;
+ const features = featuresAfter(opts.after);
+ if (!features.length) { opts.onClose?.(); return; }
+ const restoreFocus = document.activeElement as HTMLElement | null;
+ overlay = document.createElement('div'); overlay.className = 'wn-overlay';
+ const dialog = document.createElement('div'); dialog.className = 'wn-dialog ui-dialog';
+ dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true'); dialog.setAttribute('aria-labelledby', 'wn-title');
+ const head = document.createElement('div'); head.className = 'wn-head';
+ const title = document.createElement('h2'); title.id = 'wn-title'; title.textContent = tr('whatsnew.title', { version: opts.version });
+ const sub = document.createElement('p'); sub.className = 'wn-sub'; sub.textContent = tr('whatsnew.subtitle');
+ const heading = document.createElement('div'); heading.className = 'wn-heading'; heading.append(title, sub);
+ const close = document.createElement('button'); close.type = 'button'; close.className = 'wn-close';
+ close.appendChild(createIcon('close')); close.setAttribute('aria-label', tr('whatsnew.close')); close.title = tr('whatsnew.close');
+ head.append(heading, close);
+
+ const body = document.createElement('div'); body.className = 'wn-body';
+ let lastVersion = '';
+ for (const f of features) {
+ if (f.version !== lastVersion) {
+ lastVersion = f.version;
+ const v = document.createElement('h3'); v.className = 'wn-version'; v.textContent = `v${f.version}`;
+ body.appendChild(v);
+ }
+ body.appendChild(card(f, (target) => { done(); spotlight(target); }));
+ }
+
+ const foot = document.createElement('div'); foot.className = 'wn-foot';
+ const notes = document.createElement('button'); notes.type = 'button'; notes.className = 'chip';
+ notes.textContent = tr('whatsnew.all_notes');
+ notes.addEventListener('click', () => void window.devdeck.openExternal('https://github.com/writingdeveloper/devdeck/releases'));
+ const ok = document.createElement('button'); ok.type = 'button'; ok.className = 'chip wn-primary'; ok.textContent = tr('whatsnew.ok');
+ foot.append(notes, ok);
+ dialog.append(head, body, foot);
+ overlay.appendChild(dialog);
+
+ const onKey = (e: KeyboardEvent): void => {
+ if (e.key === 'Escape') { e.preventDefault(); done(); return; }
+ if (e.key !== 'Tab') return;
+ const focusable = Array.from(dialog.querySelectorAll('button:not([disabled])'));
+ const first = focusable[0], last = focusable[focusable.length - 1];
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
+ };
+ const done = (): void => {
+ if (!overlay) return;
+ document.removeEventListener('keydown', onKey, true);
+ overlay.remove(); overlay = null;
+ restoreFocus?.focus?.();
+ opts.onClose?.();
+ };
+ close.addEventListener('click', done);
+ ok.addEventListener('click', done);
+ overlay.addEventListener('mousedown', (e) => { if (e.target === overlay) done(); });
+ document.addEventListener('keydown', onKey, true);
+ document.body.appendChild(overlay);
+ ok.focus();
+}
+
+export function isWhatsNewOpen(): boolean { return overlay !== null; }
+
+function card(f: Feature, showMe: (target: NonNullable) => void): HTMLElement {
+ const el = document.createElement('article'); el.className = 'wn-card';
+ const icon = document.createElement('span'); icon.className = 'wn-icon'; icon.appendChild(createIcon(f.icon));
+ const text = document.createElement('div'); text.className = 'wn-text';
+ const h = document.createElement('h4'); h.textContent = tr(`whatsnew.f.${f.id}.title`);
+ const p = document.createElement('p'); p.textContent = tr(`whatsnew.f.${f.id}.body`);
+ const where = document.createElement('p'); where.className = 'wn-where'; where.textContent = tr(`whatsnew.f.${f.id}.where`);
+ text.append(h, p, where);
+ el.append(icon, text);
+ if (f.target) {
+ const target = f.target;
+ const b = document.createElement('button'); b.type = 'button'; b.className = 'chip wn-show'; b.textContent = tr('whatsnew.show_me');
+ b.addEventListener('click', () => showMe(target));
+ el.appendChild(b);
+ }
+ return el;
+}
+
+/** Go to where a feature lives and ring it for a moment. */
+export function spotlight(target: NonNullable): void {
+ navigate(target.view);
+ // The view draws on the next frames (the settings form loads asynchronously); look for a while.
+ const started = Date.now();
+ const find = (): void => {
+ const el = document.querySelector(target.selector);
+ if (el && el.offsetParent !== null) {
+ const ring = el.closest('.set-row') ?? el;
+ ring.scrollIntoView({ block: 'center', behavior: 'smooth' });
+ ring.classList.remove('dd-spotlight'); void ring.offsetWidth; ring.classList.add('dd-spotlight');
+ setTimeout(() => ring.classList.remove('dd-spotlight'), SPOTLIGHT_MS);
+ return;
+ }
+ if (Date.now() - started < 3000) { setTimeout(find, 100); return; }
+ toast(tr(target.view === 'cockpit' ? 'whatsnew.need_session' : 'whatsnew.not_found'));
+ };
+ requestAnimationFrame(find);
+}
diff --git a/src/shared/programStatus.test.ts b/src/shared/programStatus.test.ts
new file mode 100644
index 0000000..028e32b
--- /dev/null
+++ b/src/shared/programStatus.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest';
+import { activityFromProgramStatus, hasProgramStatusProbe, parseProgramStatus, PROGRAM_STATUS_PROBE } from './programStatus';
+
+const b64 = (s: string) => Buffer.from(s).toString('base64');
+
+describe('parseProgramStatus', () => {
+ it('reads the states Claude Code 2.1.306 was seen sending', () => {
+ // Recorded live: idle at start, working on the prompt, done with what it did.
+ expect(parseProgramStatus('state=idle:app=claude-code')).toEqual({ state: 'idle', kind: null, progress: null, msg: null });
+ expect(parseProgramStatus('state=working:app=claude-code:progress=42')).toMatchObject({ state: 'working', progress: 42 });
+ expect(parseProgramStatus(`state=done:app=claude-code:msg=${b64('replied OK as requested')}`)).toMatchObject({ state: 'done', msg: 'replied OK as requested' });
+ });
+
+ it('says why it is blocked, and keeps only kinds it knows', () => {
+ expect(parseProgramStatus('state=blocked:app=claude-code:kind=permission')).toMatchObject({ state: 'blocked', kind: 'permission' });
+ expect(parseProgramStatus('state=blocked:app=claude-code:kind=question')).toMatchObject({ kind: 'question' });
+ expect(parseProgramStatus('state=blocked:app=claude-code:kind=somethingnew')).toMatchObject({ state: 'blocked', kind: null });
+ expect(parseProgramStatus('state=working:app=claude-code:kind=permission')).toMatchObject({ kind: null });
+ });
+
+ it('decodes a message in any language', () => {
+ expect(parseProgramStatus(`state=done:app=claude-code:msg=${b64('로그인 버그 수정함')}`)?.msg).toBe('로그인 버그 수정함');
+ });
+
+ it('ignores background tasks, probes, clears, other apps and junk', () => {
+ for (const p of ['state=working:app=claude-code:id=task-1', '?', 'state=clear', 'state=clear:id=x', 'state=working:app=other', 'nonsense', '', 'state=dancing:app=claude-code']) {
+ expect(parseProgramStatus(p), p).toBeNull();
+ }
+ });
+
+ it('clamps progress', () => {
+ expect(parseProgramStatus('state=working:progress=140')?.progress).toBe(100);
+ expect(parseProgramStatus('state=working:progress=abc')?.progress).toBeNull();
+ });
+});
+
+describe('the probe', () => {
+ it('is found with either terminator', () => {
+ expect(hasProgramStatusProbe(`junk${PROGRAM_STATUS_PROBE}junk`)).toBe(true);
+ expect(hasProgramStatusProbe('\x1b]7501;?\x1b\\')).toBe(true);
+ expect(hasProgramStatusProbe('\x1b]7501;state=idle\x07')).toBe(false);
+ });
+});
+
+describe('activityFromProgramStatus', () => {
+ const s = (state: 'working' | 'blocked' | 'done' | 'idle' | 'error') => ({ state, kind: null, progress: null, msg: null });
+ it('maps the agent\'s word onto the sidebar\'s states', () => {
+ expect(activityFromProgramStatus(s('working'), 0, 10, 100)).toBe('working');
+ expect(activityFromProgramStatus(s('blocked'), 0, 10, 100)).toBe('attention');
+ expect(activityFromProgramStatus(s('done'), 0, 10, 100)).toBe('turn');
+ expect(activityFromProgramStatus(s('done'), 0, 500, 100)).toBe('idle');
+ expect(activityFromProgramStatus(s('error'), 0, 10, 100)).toBe('turn');
+ });
+});
diff --git a/src/shared/programStatus.ts b/src/shared/programStatus.ts
new file mode 100644
index 0000000..a9fcdea
--- /dev/null
+++ b/src/shared/programStatus.ts
@@ -0,0 +1,86 @@
+// src/shared/programStatus.ts
+// Claude Code's own report of what it is doing (OSC 7501, "program status"), read instead of guessed.
+//
+// Claude Code asks the terminal `ESC ] 7501 ; ? BEL` while it starts. A terminal that answers in kind
+// is then sent `ESC ] 7501 ; state=:app=claude-code[:kind=][:progress=][:msg=] BEL`
+// whenever the session's state changes (read from Claude Code 2.1.306, and observed live):
+// working a turn is running (progress, and what it is doing, when known)
+// blocked + kind it is waiting on the person: permission | question | auth (or none, a dialog)
+// done the turn finished; msg says what it did
+// idle nothing has run yet, or the turn was stopped
+// error the turn failed; msg says why
+// Entries with an `id` are background tasks inside the session, not the session itself.
+import type { ActivityState } from './sessionStatus';
+
+export type ProgramState = 'working' | 'blocked' | 'done' | 'idle' | 'error';
+export type BlockedKind = 'permission' | 'question' | 'auth';
+
+export interface ProgramStatus {
+ state: ProgramState;
+ kind: BlockedKind | null;
+ /** 0..100 when the agent knows how far along it is. */
+ progress: number | null;
+ /** One line from the agent: what it is doing, what it did, or why it stopped. */
+ msg: string | null;
+}
+
+/** The probe Claude Code sends; answered on the host, once, the moment it is seen. */
+export const PROGRAM_STATUS_PROBE = '\x1b]7501;?\x07';
+const PROBE_RE = /\x1b\]7501;\?(?:\x07|\x1b\\)/;
+/** The answer: an OSC 7501 that starts with `?` is all Claude Code checks for. */
+export const PROGRAM_STATUS_REPLY = '\x1b]7501;?\x07';
+const STATES = new Set(['working', 'blocked', 'done', 'idle', 'error']);
+const KINDS = new Set(['permission', 'question', 'auth']);
+const MSG_MAX = 300;
+
+/** Whether `text` holds the probe. */
+export function hasProgramStatusProbe(text: string): boolean {
+ return text.includes('7501;?') && PROBE_RE.test(text);
+}
+
+/**
+ * The session's own status from the payload of one OSC 7501 (everything after `7501;`). Null for a
+ * background task's entry, a probe, a `clear`, or anything malformed.
+ */
+export function parseProgramStatus(payload: string): ProgramStatus | null {
+ if (!payload || payload.startsWith('?')) return null;
+ const fields = new Map();
+ for (const part of payload.split(':')) {
+ const eq = part.indexOf('=');
+ if (eq > 0) fields.set(part.slice(0, eq), part.slice(eq + 1));
+ }
+ if (fields.has('id')) return null;
+ const state = fields.get('state') as ProgramState | undefined;
+ if (!state || !STATES.has(state)) return null;
+ const app = fields.get('app');
+ if (app !== undefined && app !== 'claude-code') return null;
+ const kind = fields.get('kind') as BlockedKind | undefined;
+ const p = Number(fields.get('progress'));
+ return {
+ state,
+ kind: state === 'blocked' && kind && KINDS.has(kind) ? kind : null,
+ progress: fields.has('progress') && Number.isFinite(p) ? Math.min(100, Math.max(0, Math.round(p))) : null,
+ msg: decodeMsg(fields.get('msg')),
+ };
+}
+
+function decodeMsg(b64: string | undefined): string | null {
+ if (!b64) return null;
+ try {
+ const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
+ const text = new TextDecoder().decode(bytes).replace(/\s+/g, ' ').trim();
+ return text ? text.slice(0, MSG_MAX) : null;
+ } catch { return null; }
+}
+
+/**
+ * The sidebar's activity from the agent's own status. A finished turn reads as "your turn" until it
+ * has sat for `idleMs`, the same window the screen-based guess uses.
+ */
+export function activityFromProgramStatus(s: ProgramStatus, sinceMs: number, nowMs: number, idleMs: number): ActivityState {
+ switch (s.state) {
+ case 'working': return 'working';
+ case 'blocked': return 'attention';
+ default: return nowMs - sinceMs < idleMs ? 'turn' : 'idle';
+ }
+}