From 406e2bd5bb0e5598535fa074817bbdc73688fd26 Mon Sep 17 00:00:00 2001 From: hedykan Date: Thu, 2 Jul 2026 13:25:39 +0800 Subject: [PATCH 1/4] feat: SELECT-key reconnect after lid-close disconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3DS 合盖休眠会冻结 TCP,导致 libssh2 会话硬断开。原 main.c 明确注释 "no auto-reconnect logic",断线后必须退出 App 重进——配合服务端 tmux 时体验很差(会话还活着,却要退出重进)。 改动(仅 source/main.c,+79/-21): 1. 抽出 reconnect_ssh() 静态函数:封装 ssh_connect_pubkey + PTY size 设置 + 成功/失败 banner + status 字符串。初始化连接与 SELECT 重连 复用同一套逻辑,消除了原本初始化处的内联重复。 2. 主循环中,当 ssh_dead(硬断线)且未开 modal 时,按 SELECT 触发重连; 连接正常时 SELECT 仍透传给 keyboard_handle_input 发 Esc(进 vim 按 SELECT 能退出)。 3. 断线瞬间(n<0 分支)一次性输出英文提示: "Connection lost" (红) / "Press SELECT to reconnect" (黄)。 因 ssh 被置 NULL,天然只触发一次。 4. 修同帧竞态:重连成功会把 ssh_dead 清零,若不另用 select_consumed 本帧标志屏蔽,同一个 SELECT 按压会漏进 keyboard 当 Esc 发进新会话 (表现为终端出现杂音字符如 ])。 体验:开盖 → 按 SELECT → 秒回原会话,全程不退出 DSSH。 真机验证通过。 --- source/main.c | 100 +++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 79 insertions(+), 21 deletions(-) diff --git a/source/main.c b/source/main.c index 402375d..e4cebfd 100644 --- a/source/main.c +++ b/source/main.c @@ -6,6 +6,9 @@ * (L=Shift, Y=Ctrl, X=Alt — see source/keyboard.c). No more * SELECT-driven sticky-Ctrl state machine. * - SELECT now emits Esc, R now toggles IME mode (EN ↔ CN). + * SELECT is overloaded: when the SSH session is dead (hard disconnect + * after lid-close sleep), pressing SELECT reconnects instead of emit- + * ting Esc — so the user can recover without exiting DSSH (M13). * - Bottom screen is the full custom soft keyboard (source/softkb.c) * — no more system swkbd applet popup. X key is for Alt now. * - Touch screen drives the soft keyboard. Tapped letter goes through @@ -117,6 +120,36 @@ static void send_to_ssh(ssh_client_t *ssh, terminal_t *term, g_last_tx_at = time(NULL); } +/* Establish (or re-establish) the SSH session using the loaded config. + * Used both for the initial connect at startup and for the SELECT-key + * reconnect after a hard disconnect (lid-close sleep kills the TCP). + * On success: returns a new ssh_client_t, sizes the PTY, writes the + * green "connected." banner + a status string. On failure: returns + * NULL, writes the red SSH-error banner + the diagnostic in err. */ +static ssh_client_t *reconnect_ssh(const ssh_config_t *cfg, + terminal_t *term, + char *status_buf, int status_sz, + char *err, int err_sz) { + ssh_client_t *ssh = ssh_connect_pubkey( + cfg->host, cfg->port, cfg->user, + cfg->key_path, NULL, + cfg->passphrase[0] ? cfg->passphrase : NULL, + err, err_sz); + + if (!ssh) { + char line[256]; + snprintf(line, sizeof(line), "\x1b[31mSSH error:\x1b[0m %s\r\n", err); + terminal_write(term, line); + snprintf(status_buf, status_sz, "ssh err"); + return NULL; + } + + terminal_write(term, "\x1b[32mconnected.\x1b[0m\r\n"); + ssh_set_pty_size(ssh, R_TOP_COLS, R_TOP_ROWS); + snprintf(status_buf, status_sz, "connected %s:%d", cfg->host, cfg->port); + return ssh; +} + int main(int argc, char *argv[]) { char err[256] = {0}; char status_buf[80] = "starting..."; @@ -234,25 +267,9 @@ int main(int argc, char *argv[]) { C3D_FrameEnd(0); } - ssh = ssh_connect_pubkey( - cfg.host, cfg.port, cfg.user, - cfg.key_path, NULL, - cfg.passphrase[0] ? cfg.passphrase : NULL, - err, sizeof(err)); - - if (!ssh) { - char line[256]; - snprintf(line, sizeof(line), "\x1b[31mSSH error:\x1b[0m %s\r\n", err); - terminal_write(term, line); - snprintf(status_buf, sizeof(status_buf), "ssh err"); - status_color = COLOR_ERR; - } else { - terminal_write(term, "\x1b[32mconnected.\x1b[0m\r\n"); - ssh_set_pty_size(ssh, R_TOP_COLS, R_TOP_ROWS); - snprintf(status_buf, sizeof(status_buf), "connected %s:%d", - cfg.host, cfg.port); - status_color = COLOR_OK; - } + ssh = reconnect_ssh(&cfg, term, status_buf, sizeof(status_buf), + err, sizeof(err)); + status_color = ssh ? COLOR_OK : COLOR_ERR; idle_loop: { @@ -333,11 +350,18 @@ int main(int argc, char *argv[]) { feed_terminal(term, rbuf, n); last_rx_at = time(NULL); } else if (n < 0) { - /* Hard disconnect — silent. Mascot raises ✕ via - * the ssh_dead flag below; no terminal banner. */ + /* Hard disconnect. Tear down the session, mark it + * dead so the mascot raises ✕, and show a one-shot + * red banner telling the user the connection broke + * and how to recover (SELECT reconnect). This only + * fires on the 0→1 transition of ssh_dead, so it + * won't spam the terminal every frame. */ ssh_disconnect(ssh); ssh = NULL; ssh_dead = 1; + terminal_write(term, + "\r\n\x1b[31mConnection lost\r\n\x1b[0m" + "\x1b[33mPress SELECT to reconnect\r\n\x1b[0m"); } } @@ -353,6 +377,37 @@ int main(int argc, char *argv[]) { mascot_set_alert(mc, stall_alert); } + /* ── SELECT reconnect (only when the session is dead) ── + * A hard disconnect (lid-close sleep) leaves ssh_dead=1. + * Pressing SELECT here reuses the loaded config to open a + * fresh session, so the user recovers without relaunching. + * While connected, SELECT falls through to keyboard_handle_input + * as Esc (see the input_down mask below). + * + * select_consumed records that THIS frame's SELECT triggered + * a reconnect. It can't be derived from ssh_dead because a + * successful reconnect clears ssh_dead to 0 in this same + * frame — without an independent flag, that just-used SELECT + * would slip through to keyboard_handle_input as a stray Esc + * into the freshly opened session. */ + int select_consumed = 0; + if (ssh_dead && !modal_open && (down & KEY_SELECT)) { + select_consumed = 1; + terminal_write(term, "\x1b[33mreconnecting...\x1b[0m\r\n"); + ssh = reconnect_ssh(&cfg, term, status_buf, sizeof(status_buf), + err, sizeof(err)); + if (ssh) { + ssh_dead = 0; + stall_alert = 0; + mascot_set_alert(mc, 0); + last_rx_at = time(NULL); + g_last_tx_at = last_rx_at; + } else { + terminal_write(term, + "\x1b[31mreconnect failed; press SELECT to retry.\x1b[0m\r\n"); + } + } + /* ── Physical keys (Esc / Enter / BS / D-pad / R / scroll) ── * When the AI-ask modal is showing (or animating), suppress * the keyboard handler entirely so A=close-keep doesn't @@ -362,6 +417,9 @@ int main(int argc, char *argv[]) { * modifier release detection coherent for the post-modal * world) but registers no edge events. */ u32 input_down = modal_open ? 0u : down; + /* Mask SELECT when the session is dead or was just consumed by + * the reconnect above, so it isn't emitted as Esc. */ + if (ssh_dead || select_consumed) input_down &= ~KEY_SELECT; const char *out = keyboard_handle_input(kbd, term, input_down, held, cpad.dy); if (out && !modal_open) send_to_ssh(ssh, term, out, (int)strlen(out)); From e94c61c7c73a63ff452b7cde272b962e638f7506 Mon Sep 17 00:00:00 2001 From: hedykan Date: Fri, 3 Jul 2026 02:30:57 +0800 Subject: [PATCH 2/4] feat: mascot states for reconnect, voice, typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add six new states to the crab mascot, all driven externally by main.c: LOOKUP reconnect in progress — crab freezes, looks up, cycles '...' dots above its head. mascot_set_reconnecting() (idempotent). HAPPY reconnect succeeded — brief hop with green ✓, auto-expires. mascot_celebrate() (one-shot). SAD reconnect failed — tilt/shiver with red !, auto-expires back to ALERT. mascot_sadden() (one-shot). RECORD voice recording — crab holds a mic icon, bobs to the beat. mascot_set_recording() (idempotent). THINK voice transcribing — crab pauses, '?' above head. mascot_set_thinking() (idempotent). TYPE typing — salmon-pink speech bubble with growing dots ('.' → '..' → '...'). mascot_type_kick() refreshes a ~300 ms timer; re-kicks during continuous typing only refresh the timer so the dot cycle plays uninterrupted, and it auto-expires ~300 ms after the last kick. Animation phase (type_phase) is decoupled from the expiry timer (state_frames) to avoid the dots freezing mid-cycle on rapid input. --- source/mascot.c | 355 +++++++++++++++++++++++++++++++++++++++++++++++- source/mascot.h | 44 ++++++ 2 files changed, 396 insertions(+), 3 deletions(-) diff --git a/source/mascot.c b/source/mascot.c index e272f03..0cf8f8e 100644 --- a/source/mascot.c +++ b/source/mascot.c @@ -29,6 +29,12 @@ typedef enum { STATE_IDLE, STATE_FLEE, STATE_ALERT, + STATE_LOOKUP, /* reconnecting: look up + "..." dots */ + STATE_HAPPY, /* reconnect succeeded: hop + green ✓ */ + STATE_SAD, /* reconnect failed: shiver + red ! */ + STATE_RECORD, /* voice recording: mic + bob to beat */ + STATE_THINK, /* voice transcribing: thinking pose + "?" */ + STATE_TYPE, /* typing: claw-tap as if on a keyboard */ } mascot_state_t; struct mascot_t { @@ -42,6 +48,12 @@ struct mascot_t { int anim_timer; int bob_phase; int alert_phase; + int dot_phase; /* LOOKUP: cycles the "..." dots */ + int hop_dy; /* HAPPY: vertical hop offset */ + int shiver_phase; /* SAD: shiver tick */ + int rec_bob; /* RECORD: beat-bob offset */ + int think_phase; /* THINK: "?" sway */ + int type_phase; /* TYPE: claw-tap cycle (0..11) */ }; #define CRAB_W 18 @@ -97,6 +109,48 @@ static const char *const alert_x[5] = { "@...@", }; +/* 5×5 green ✓ for HAPPY. */ +static const char *const happy_check[5] = { + "....@", + "...@.", + "@.@..", + ".@...", + "@....", +}; + +/* 3×5 red ! for SAD. */ +static const char *const sad_bang[5] = { + "@@.", + "@@.", + "@@.", + "...", + "@@.", +}; + +/* 5×6 mic for RECORD (capsule head + stem + little base). */ +static const char *const mic_icon[6] = { + ".@@@.", + "@@@@@", + "@@@@@", + ".@@@.", + "..@..", + ".@@@.", +}; + +/* 5×5 blue ? for THINK. */ +static const char *const think_q[5] = { + ".@@@.", + "@...@", + "..@@.", + "..@..", + "..@..", +}; + +#define COL_CHECK 0x40b061ff /* green */ +#define COL_MIC 0xe0c06cff /* warm gold */ +#define COL_Q 0x6fa8dcff /* soft blue */ +#define COL_BANG 0xe54040ff /* red (same hue as ✕) */ + static u32 rgba_to_c2d(uint32_t rgba) { return C2D_Color32((rgba >> 24) & 0xff, (rgba >> 16) & 0xff, @@ -116,6 +170,54 @@ static void enter_flee(mascot_t *m) { m->state = STATE_FLEE; m->state_frames = 50 + (rand() % 30); } +static void enter_lookup(mascot_t *m) { + m->prev_state = m->state; + m->state = STATE_LOOKUP; + m->dot_phase = 0; + m->state_frames = 0; +} +static void enter_happy(mascot_t *m) { + m->prev_state = m->state; + m->state = STATE_HAPPY; + m->hop_dy = 0; + m->state_frames = 1; +} +static void enter_sad(mascot_t *m) { + m->prev_state = m->state; + m->state = STATE_SAD; + m->shiver_phase = 0; + m->state_frames = 1; +} +static void enter_record(mascot_t *m) { + m->prev_state = m->state; + m->state = STATE_RECORD; + m->rec_bob = 0; + m->state_frames = 0; +} +static void enter_think(mascot_t *m) { + m->prev_state = m->state; + m->state = STATE_THINK; + m->think_phase = 0; + m->state_frames = 0; +} + +#define TYPE_TIMEOUT_FRAMES 18 /* ~300 ms at 60 fps */ + +static void enter_type(mascot_t *m) { + /* Don't interrupt the higher-priority status poses. */ + if (m->state == STATE_ALERT || m->state == STATE_LOOKUP || + m->state == STATE_HAPPY || m->state == STATE_SAD) return; + /* Only reset the dot-cycle phase when freshly entering TYPE. If the + * crab is already typing, leave type_phase running so the "." → "..." + * growth animation completes instead of restarting on every keystroke + * (which would freeze it at the first dot during continuous input). */ + if (m->state != STATE_TYPE) { + m->prev_state = m->state; + m->type_phase = 0; + } + m->state = STATE_TYPE; + m->state_frames = TYPE_TIMEOUT_FRAMES; /* refresh the auto-expire timer */ +} mascot_t *mascot_init(int x_min, int x_max, int y) { mascot_t *m = calloc(1, sizeof(*m)); @@ -142,6 +244,56 @@ void mascot_set_alert(mascot_t *m, int alert) { } } +void mascot_set_reconnecting(mascot_t *m, int on) { + if (!m) return; + if (on && m->state != STATE_LOOKUP) { + enter_lookup(m); + } else if (!on && m->state == STATE_LOOKUP) { + /* Handshake ended; hand back to caller / alert loop. If the + * session was dead when we started looking up, returning to + * ALERT keeps the ✕ up until success clears it. */ + if (m->prev_state == STATE_ALERT) { + m->state = STATE_ALERT; + m->alert_phase = 0; + } else { + enter_walk(m); + } + } +} + +void mascot_celebrate(mascot_t *m) { + if (!m) return; + enter_happy(m); +} + +void mascot_sadden(mascot_t *m) { + if (!m) return; + enter_sad(m); +} + +void mascot_set_recording(mascot_t *m, int on) { + if (!m) return; + if (on && m->state != STATE_RECORD) { + enter_record(m); + } else if (!on && m->state == STATE_RECORD) { + enter_walk(m); + } +} + +void mascot_set_thinking(mascot_t *m, int on) { + if (!m) return; + if (on && m->state != STATE_THINK) { + enter_think(m); + } else if (!on && m->state == STATE_THINK) { + enter_walk(m); + } +} + +void mascot_type_kick(mascot_t *m) { + if (!m) return; + enter_type(m); +} + static void clamp_and_bounce(mascot_t *m, int *hit_wall) { *hit_wall = 0; if (m->fx <= (float)m->x_min) { @@ -167,8 +319,13 @@ void mascot_update(mascot_t *m) { m->anim_frame = (m->anim_frame + 1) & 7; } } - m->bob_phase = (m->bob_phase + 1) % 60; - m->alert_phase = (m->alert_phase + 1) % 24; + m->bob_phase = (m->bob_phase + 1) % 60; + m->alert_phase = (m->alert_phase + 1) % 24; + m->dot_phase = (m->dot_phase + 1) % 36; /* LOOKUP dot cycle */ + m->shiver_phase = (m->shiver_phase + 1) % 8; /* SAD shiver */ + m->rec_bob = (m->rec_bob + 1) % 24; /* RECORD beat */ + m->think_phase = (m->think_phase + 1) % 30; /* THINK sway */ + m->type_phase = (m->type_phase + 1) % 18; /* TYPE bubble dot cycle */ int hit_wall = 0; switch (m->state) { @@ -192,6 +349,49 @@ void mascot_update(mascot_t *m) { case STATE_ALERT: break; + + case STATE_LOOKUP: + /* Frozen in place; the "..." dots animate in draw(). */ + break; + + case STATE_HAPPY: { + /* 40-frame hop (~0.67 s): rise then fall via a parabola, + * peak ≈ 5 px up at the midpoint. Then return to WALK. */ + int t = m->state_frames; + int h = (t < 20) ? t : (40 - t); + m->hop_dy = -(h / 4); /* 0 → -5 → 0 */ + if (++m->state_frames >= 40) enter_walk(m); + break; + } + + case STATE_SAD: + /* Shiver in place for ~90 frames (~1.5 s), then back to ALERT + * so the ✕ (and the dead-session signal) returns. */ + if (++m->state_frames >= 90) { + m->state = STATE_ALERT; + m->alert_phase = 0; + } + break; + + case STATE_RECORD: + case STATE_THINK: + /* Held externally; no auto-transition. Body animation is + * driven off rec_bob / think_phase in draw(). */ + break; + + case STATE_TYPE: + /* Auto-expire ~300 ms after the last kick; return to whatever + * the crab was doing before typing started (usually WALK). */ + if (m->state_frames > 0) m->state_frames--; + if (m->state_frames == 0) { + mascot_state_t p = m->prev_state; + /* Don't restore into an obsolete pose; WALK is the safe + * default. ALERT/etc. are never stored as prev by + * enter_type (it refuses to interrupt them). */ + enter_walk(m); + (void)p; + } + break; } } @@ -203,6 +403,19 @@ void mascot_draw(mascot_t *m) { /* Idle small bob. */ if (m->state == STATE_IDLE && ((m->bob_phase / 8) & 1)) yi -= 1; + /* HAPPY hop: whole body rises by hop_dy (≤ 0). */ + if (m->state == STATE_HAPPY) yi += m->hop_dy; + + /* SAD shiver: jitter ±1 px horizontally. */ + if (m->state == STATE_SAD) xi += (m->shiver_phase < 4) ? 0 : -1; + + /* RECORD beat-bob: bounce ±1 px in time to a ~5 Hz beat, like the + * crab is nodding along to your voice. */ + if (m->state == STATE_RECORD) yi -= (m->rec_bob < 12) ? 1 : 0; + + /* THINK gentle sway: drift ±1 px horizontally, slower than SAD. */ + if (m->state == STATE_THINK) xi += (m->think_phase < 15) ? 0 : -1; + /* Sway state — only WALK/FLEE animate; IDLE/ALERT freeze at tilt 0. */ int tilt = 0; int body_dy = 0; @@ -247,6 +460,44 @@ void mascot_draw(mascot_t *m) { 0.6f, 1, 1, foot_c); } + /* TYPE "typing…" speech bubble: a salmon-pink rounded-rect outline + * floats above the crab with a little tail pointing down at it, and + * 1-3 dots grow inside in sequence (. → .. → ... → blank → repeat) + * to read as "typing". More legible at 18×11 body scale than a + * limb animation. */ + if (m->state == STATE_TYPE) { + u32 c = rgba_to_c2d(COL_BODY); + /* Bubble geometry: 11 wide × 7 tall, centered over the body. */ + const int BW = 11, BH = 7; + int bx = xi + (CRAB_W - BW) / 2; + int by = yi - BH - 3; /* 3-px gap above the body */ + /* Rounded-rect outline (corners left open → reads rounded). */ + for (int y = 0; y < BH; y++) { + for (int x = 0; x < BW; x++) { + int edge = (y == 0 || y == BH - 1 || x == 0 || x == BW - 1); + int corner = (x < 1 || x > BW - 2) && (y < 1 || y > BH - 2); + if (edge && !corner) { + C2D_DrawRectSolid((float)(bx + x), (float)(by + y), + 0.7f, 1, 1, c); + } + } + } + /* Tail: two pixels dropping from the bubble's bottom edge toward + * the crab's head, offset left of center. */ + int tx = bx + 3; + C2D_DrawRectSolid((float)(tx), (float)(by + BH), 0.7f, 1, 1, c); + C2D_DrawRectSolid((float)(tx), (float)(by + BH + 1), 0.7f, 1, 1, c); + /* Growing dots: one new dot lights every 5 frames, then a brief + * blank pause before the cycle restarts. */ + int ndots = (m->type_phase < 15) ? (m->type_phase / 5) + 1 : 0; + if (ndots > 3) ndots = 3; + for (int d = 0; d < ndots; d++) { + int dx = bx + 3 + d * 2; /* dots at x=3,5,7 inside bubble */ + int dy = by + 3; /* vertically centered */ + C2D_DrawRectSolid((float)(dx), (float)(dy), 0.75f, 1, 1, c); + } + } + /* Red ✕ overlay for ALERT. */ if (m->state == STATE_ALERT) { u32 x_c = rgba_to_c2d(COL_X); @@ -264,6 +515,99 @@ void mascot_draw(mascot_t *m) { } } } + + /* "..." dots above the head for LOOKUP. Three dots, each 2×2, spaced + * 3 px apart and centered over the body; they brighten in sequence to + * read as a loading animation (one new dot lights up every ~0.2 s). */ + if (m->state == STATE_LOOKUP) { + u32 dim = C2D_Color32(0xcc, 0xcc, 0xcc, 0x80); + u32 lit = C2D_Color32(0xff, 0xff, 0xff, 0xff); + int lit_count = (m->dot_phase / 6) % 4; /* 0..3 dots */ + int base_x = xi + (CRAB_W - 10) / 2; /* center 10-px strip */ + int base_y = yi - 5; + for (int d = 0; d < 3; d++) { + u32 c = (d < lit_count) ? lit : dim; + int dx = base_x + d * 4; + for (int row = 0; row < 2; row++) { + for (int col = 0; col < 2; col++) { + C2D_DrawRectSolid((float)(dx + col), + (float)(base_y + row), + 0.7f, 1, 1, c); + } + } + } + } + + /* Green ✓ overlay for HAPPY (static, centered above the hop). */ + if (m->state == STATE_HAPPY) { + u32 c = rgba_to_c2d(COL_CHECK); + int cx = xi + (CRAB_W - 5) / 2; + int cy = yi - 6; + for (int row = 0; row < 5; row++) { + const char *src = happy_check[row]; + for (int col = 0; col < 5; col++) { + if (src[col] == '@') { + C2D_DrawRectSolid((float)(cx + col), + (float)(cy + row), + 0.7f, 1, 1, c); + } + } + } + } + + /* Red ! overlay for SAD (sways gently with the shiver). */ + if (m->state == STATE_SAD) { + u32 c = rgba_to_c2d(COL_BANG); + int sway = ((m->shiver_phase < 4) ? 0 : -1); + int bx = xi + (CRAB_W - 3) / 2 + sway; + int by = yi - 7; + for (int row = 0; row < 5; row++) { + const char *src = sad_bang[row]; + for (int col = 0; col < 3; col++) { + if (src[col] == '@') { + C2D_DrawRectSolid((float)(bx + col), + (float)(by + row), + 0.7f, 1, 1, c); + } + } + } + } + + /* Gold mic overlay for RECORD — held just above the body; a 1-px + * arm connects it to the crab so it reads as "held up". */ + if (m->state == STATE_RECORD) { + u32 c = rgba_to_c2d(COL_MIC); + int mx = xi + (CRAB_W - 5) / 2; + int my = yi - 7; + for (int row = 0; row < 6; row++) { + const char *src = mic_icon[row]; + for (int col = 0; col < 5; col++) { + if (src[col] == '@') { + C2D_DrawRectSolid((float)(mx + col), + (float)(my + row), + 0.7f, 1, 1, c); + } + } + } + } + + /* Blue ? overlay for THINK (sways gently with the think drift). */ + if (m->state == STATE_THINK) { + u32 c = rgba_to_c2d(COL_Q); + int sway = ((m->think_phase < 15) ? 0 : -1); + int qx = xi + (CRAB_W - 5) / 2 + sway; + int qy = yi - 6; + for (int row = 0; row < 5; row++) { + const char *src = think_q[row]; + for (int col = 0; col < 5; col++) { + if (src[col] == '@') { + C2D_DrawRectSolid((float)(qx + col), + (float)(qy + row), + 0.7f, 1, 1, c); + } + } + } + } } int mascot_hit_test(const mascot_t *m, int tx, int ty) { @@ -276,7 +620,12 @@ int mascot_hit_test(const mascot_t *m, int tx, int ty) { void mascot_on_touched(mascot_t *m, int from_tx) { if (!m) return; - if (m->state == STATE_ALERT) return; + /* Don't flee out of the reconnect- or voice-related states — the + * crab is busy signaling and shouldn't run away. */ + if (m->state == STATE_ALERT || m->state == STATE_LOOKUP || + m->state == STATE_HAPPY || m->state == STATE_SAD || + m->state == STATE_RECORD || m->state == STATE_THINK || + m->state == STATE_TYPE) return; int center = (int)m->fx + CRAB_W / 2; m->facing = (from_tx < center) ? +1 : -1; enter_flee(m); diff --git a/source/mascot.h b/source/mascot.h index 3ec71b9..cb971b1 100644 --- a/source/mascot.h +++ b/source/mascot.h @@ -12,6 +12,24 @@ * ALERT set externally by main.c when the SSH connection has stalled. * Crab freezes and holds up a red ✕, waving it gently until * the alert is cleared. + * LOOKUP reconnect in progress: crab freezes, looks up, and three + * dots "..." cycle above its head. Set externally while the + * blocking SSH handshake is in flight; cleared on success/fail. + * HAPPY reconnect succeeded: a brief hop with a green ✓, then back + * to WALK. One-shot, auto-expires. + * SAD reconnect failed: crab tilts and shivers with a red !, + * then back to ALERT. One-shot, auto-expires. + * RECORD voice recording: crab holds up a microphone icon and bobs + * in time to the beat, like it's listening. Set externally + * while the mic is capturing. + * THINK voice transcribing: crab pauses in a "thinking" pose with a + * ? above its head while the server transcribes. Set externally + * around the blocking ASR round-trip. + * TYPE typing: crab taps its claws as if on a keyboard. Kicked per + * character by mascot_type_kick() — re-kicks refresh the timer + * so a run of typing (soft-key press, handwriting, voice TYPING + * streaming) keeps the crab typing continuously; once input + * stops it auto-expires back to its prior state (~300 ms). * * All coordinates are bottom-screen pixel space (320×240, top-left origin). */ @@ -36,3 +54,29 @@ void mascot_on_touched(mascot_t *m, int from_tx); * main.c when SSH stall detection fires; idempotent — repeated calls * with the same value are no-ops. */ void mascot_set_alert(mascot_t *m, int alert); + +/* Toggle the reconnecting state (crab looks up, "..." dots above). + * Called by main.c around the blocking SSH handshake; idempotent. */ +void mascot_set_reconnecting(mascot_t *m, int on); + +/* One-shot reconnect-succeeded celebration (hop + green ✓). + * Auto-expires back to WALK after a moment. */ +void mascot_celebrate(mascot_t *m); + +/* One-shot reconnect-failed reaction (tilt/shiver + red !). + * Auto-expires back to ALERT after a moment. */ +void mascot_sadden(mascot_t *m); + +/* Toggle the voice-recording state (crab holds a mic, bobs to beat). + * Called by main.c while the mic is capturing; idempotent. */ +void mascot_set_recording(mascot_t *m, int on); + +/* Toggle the thinking state (crab pauses, "?" above head) while the + * server transcribes voice input; idempotent. */ +void mascot_set_thinking(mascot_t *m, int on); + +/* Kick one "typing" cycle (~300 ms): the crab taps its claws like it's + * mashing a keyboard. Call once per character sent to SSH — re-kicks + * while active just refresh the timer so continuous typing stays in the + * pose, and it auto-expires ~300 ms after the last character. */ +void mascot_type_kick(mascot_t *m); From d3411fc0c44a9511bc77bb83f3ec5f76b4c0802d Mon Sep 17 00:00:00 2001 From: hedykan Date: Fri, 3 Jul 2026 02:31:07 +0800 Subject: [PATCH 3/4] feat: voice typewriter streaming + VOICE_TYPING state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single-shot dump of transcribed text with a typewriter stream: the reply is forwarded to the SSH shell one UTF-8 glyph at a time, with a per-glyph delay that scales inversely with total length so long utterances don't crawl. - New VOICE_TYPING state between TRANSCRIBING and IDLE. - utf8_char_len() helper walks multibyte boundaries safely; clamped to reply_len so a truncated tail can't overrun. - type_delay tiers (5/3/2/2/1 frames by length) tuned so a short word feels snappy while a paragraph still streams in a second or two. - voice_consume_typed() latches one glyph-per-call so main.c can kick the mascot's typing pose in sync (this path bypasses send_to_ssh, which is where soft-key / handwriting kicks originate). - flush_typing() dumps the remainder on START interrupt so a re-trigger doesn't leave half-streamed text behind. voice_toggle() / voice_ai_toggle() both handle VOICE_TYPING (interrupt → IDLE), and the stall detector in main.c is suppressed while voice is busy so the 5-s-no-rx alert doesn't fire during the ASR round-trip. --- source/voice.c | 99 ++++++++++++++++++++++++++++++++++++++++++++++++-- source/voice.h | 11 ++++++ 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/source/voice.c b/source/voice.c index 7993e81..cfed514 100644 --- a/source/voice.c +++ b/source/voice.c @@ -74,6 +74,17 @@ struct voice_t { char reply_buf[REPLY_BUF_SIZE]; int reply_len; + /* Typewriter streaming (VOICE_TYPING). type_pos is the byte offset + * into reply_buf already sent; type_next_at is the state_frame value + * at which the next character goes out. Per-char delay (frames) is + * set by enter_typing() scaling inversely with reply_len. */ + int type_pos; + int type_next_at; + int type_delay; + int typed_latch; /* set when a glyph streamed; cleared by + voice_consume_typed() so main.c can + kick the mascot typing pose per glyph. */ + /* AI-ask state. ai_question + ai_answer are filled from the JSON * response body after VOICE_TRANSCRIBING completes; ai_history is * appended to on close_keep, cleared on close_clear. */ @@ -100,6 +111,46 @@ static void enter_error(voice_t *v, const char *msg) { if (msg) snprintf(v->err_msg, sizeof(v->err_msg), "%s", msg); } +/* Length in bytes of the UTF-8 codepoint starting at b (1..4), or 1 for + * any invalid/over-long lead so the typewriter never splits a glyph. */ +static int utf8_char_len(const unsigned char *b) { + if (b[0] < 0x80) return 1; + if ((b[0] & 0xE0) == 0xC0) return 2; + if ((b[0] & 0xF0) == 0xE0) return 3; + if ((b[0] & 0xF8) == 0xF0) return 4; + return 1; /* fallback — treat as single byte */ +} + +/* Begin streaming reply_buf to the shell one glyph at a time. Longer + * replies get a shorter per-char delay so a full sentence doesn't take + * forever: ~5 frames/char under 30 chars, scaling down to ~1 frame/char + * for very long replies (~60 fps → 83 ms down to ~17 ms). */ +static void enter_typing(voice_t *v) { + v->state = VOICE_TYPING; + v->state_frame = 0; + v->type_pos = 0; + v->type_next_at = 0; + int n = v->reply_len; + int delay; + if (n < 20) delay = 5; + else if (n < 40) delay = 3; + else if (n < 80) delay = 2; + else if (n < 160) delay = 2; + else delay = 1; + v->type_delay = delay; +} + +/* Dump any remaining reply_buf in one shot and return to IDLE. Used when + * the user interrupts the typewriter with a second START — they don't lose + * the rest of the text, just the gradual reveal. */ +static void flush_typing(voice_t *v, ssh_client_t *ssh) { + if (v->type_pos < v->reply_len) { + ssh_write(ssh, v->reply_buf + v->type_pos, + v->reply_len - v->type_pos); + } + enter_idle(v); +} + static void release_aux(voice_t *v) { if (v->aux) { ssh_aux_close(v->aux); @@ -475,6 +526,10 @@ void voice_toggle(voice_t *v, ssh_client_t *ssh) { release_aux(v); enter_idle(v); break; + case VOICE_TYPING: + /* Interrupt the typewriter: flush the rest instantly. */ + flush_typing(v, ssh); + break; case VOICE_AI_SHOWING: /* Plain START while modal up — let the modal stay; the * caller (main.c) routes start back to us only when modal @@ -505,6 +560,11 @@ void voice_ai_toggle(voice_t *v, ssh_client_t *ssh) { release_aux(v); enter_idle(v); break; + case VOICE_TYPING: + /* Non-AI reply still streaming; L+START mid-typing just + * flushes it and returns to idle (this cycle wasn't AI). */ + flush_typing(v, ssh); + break; case VOICE_AI_SHOWING: /* Modal up — main.c shouldn't route here; defensive ignore. */ break; @@ -637,16 +697,41 @@ void voice_tick(voice_t *v, ssh_client_t *ssh) { finish_ai_transcribe(v); } else { if (v->reply_len > 0) { - ssh_write(ssh, v->reply_buf, v->reply_len); + /* Release the aux channel but keep reply_buf; + * VOICE_TYPING will stream it out glyph by glyph. */ + release_aux(v); + enter_typing(v); + } else { + release_aux(v); + enter_idle(v); } - release_aux(v); - enter_idle(v); } } } break; } + case VOICE_TYPING: { + /* Emit one UTF-8 glyph every type_delay frames until the whole + * reply has been streamed, then return to IDLE. ssh_write on + * a whole codepoint keeps multi-byte CJK glyphs intact. */ + if (v->state_frame >= v->type_next_at) { + if (v->type_pos < v->reply_len) { + int clen = utf8_char_len((const unsigned char *) + (v->reply_buf + v->type_pos)); + if (v->type_pos + clen > v->reply_len) + clen = v->reply_len - v->type_pos; + ssh_write(ssh, v->reply_buf + v->type_pos, clen); + v->type_pos += clen; + v->type_next_at = v->state_frame + v->type_delay; + v->typed_latch = 1; /* signal main.c to kick mascot */ + } else { + enter_idle(v); + } + } + break; + } + case VOICE_AI_SHOWING: /* Idle until close_keep / close_clear. state_frame keeps * incrementing so the modal renderer can drive its fade-in @@ -689,6 +774,7 @@ const char *voice_status_label(const voice_t *v) { case VOICE_RECORDING: return v->ai_mode ? "AI?" : "REC"; case VOICE_TRANSCRIBING: + case VOICE_TYPING: return spinner_frame(v->state_frame); case VOICE_ERROR: return "ERR"; @@ -697,6 +783,13 @@ const char *voice_status_label(const voice_t *v) { } } +int voice_consume_typed(voice_t *v) { + if (!v) return 0; + int was = v->typed_latch; + v->typed_latch = 0; + return was; +} + uint32_t voice_status_bg(const voice_t *v) { if (!v) return 0; switch (v->state) { diff --git a/source/voice.h b/source/voice.h index 8a4be51..cc78075 100644 --- a/source/voice.h +++ b/source/voice.h @@ -27,6 +27,11 @@ typedef enum { VOICE_IDLE = 0, VOICE_RECORDING, VOICE_TRANSCRIBING, + /* Non-AI transcription received; the reply text is being streamed + * back to the SSH shell one UTF-8 character at a time (typewriter + * effect) rather than dumped all at once. Per-char delay scales + * inversely with total length so long utterances don't crawl. */ + VOICE_TYPING, /* M12: AI-ask modal is showing. voice_state_frame() drives the * 0.5 s fade-in and the modal renderer. Stays in this state until * voice_ai_close_keep() (A) or voice_ai_close_clear() (B / touch). */ @@ -79,6 +84,12 @@ int voice_state_frame(const voice_t *v); * modifier label). */ const char *voice_status_label(const voice_t *v); +/* True iff the typewriter streamed at least one glyph since the last + * call to this function (latches + clears). main.c uses it to kick the + * mascot's typing pose in sync with voice TYPING output, which bypasses + * send_to_ssh (voice.c talks to ssh_write directly). */ +int voice_consume_typed(voice_t *v); + /* Background tint for the status slot during voice activity (RGBA); * 0 = no tint (use the caller's default). */ uint32_t voice_status_bg(const voice_t *v); From 78abaa6c6e8544d4de8cd1da4e0c3f040882ad03 Mon Sep 17 00:00:00 2001 From: hedykan Date: Fri, 3 Jul 2026 02:31:15 +0800 Subject: [PATCH 4/4] feat: wire mascot into reconnect, voice, type-kick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connect the new mascot and voice APIs in the main loop: - send_to_ssh() now takes the mascot and calls mascot_type_kick() on every send, so soft-key presses and handwriting commits peck the crab's claws — the same TYPE animation voice TYPING streams reuse. - Around the blocking SSH handshake (SELECT reconnect): draw one full frame (top screen + soft keyboard + clock + mascot) before entering reconnect_ssh() so the user sees 'Reconnecting...' with the LOOKUP crab instead of a frozen screen, then celebrate()/sadden() on success/fail. The redraw mirrors the main render path so the keyboard and clock don't vanish during reconnect. - Voice ↔ mascot link is level-driven every frame (not edge-triggered) because voice_toggle() sets the state before prev_state is sampled; RECORDING → mic pose, TRANSCRIBING → ? pose. The voice TYPING glyph latch kicks the typing pose instead of a static one. - Suppress the SSH stall alert entirely while voice is busy (RECORDING/TRANSCRIBING/TYPING) — the aux ASR channel legitimately produces 5 s+ of no-rx and would otherwise fire a false ALERT. Also gitignore deploy-3ds.sh (host-side 3dslink network deploy helper, local dev tooling, not part of the app). --- source/main.c | 108 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 92 insertions(+), 16 deletions(-) diff --git a/source/main.c b/source/main.c index e4cebfd..ba0a11c 100644 --- a/source/main.c +++ b/source/main.c @@ -113,11 +113,14 @@ static time_t g_last_tx_at = 0; * scrollback peek) right before sending a key. This way the user always * sees what they just typed, even if they were glancing at history. */ static void send_to_ssh(ssh_client_t *ssh, terminal_t *term, - const char *bytes, int n) { + const char *bytes, int n, mascot_t *mc) { if (!ssh || !ssh_is_connected(ssh) || n <= 0) return; if (term && term->sb_offset != 0) terminal_scroll_view(term, -term->sb_offset); ssh_write(ssh, bytes, n); g_last_tx_at = time(NULL); + /* Kick the typing animation once per send so the crab pecks its + * claws on every keystroke / handwriting commit / voice glyph. */ + if (mc) mascot_type_kick(mc); } /* Establish (or re-establish) the SSH session using the loaded config. @@ -331,14 +334,40 @@ int main(int argc, char *argv[]) { * AI transcribe; we open the modal once that happens. */ voice_state_t prev_state = voice_state(voice); voice_tick(voice, ssh); + voice_state_t cur_state = voice_state(voice); if (prev_state != VOICE_AI_SHOWING && - voice_state(voice) == VOICE_AI_SHOWING) { + cur_state == VOICE_AI_SHOWING) { ai_modal_open(aim, voice_ai_question(voice), voice_ai_answer(voice)); } ai_modal_tick(aim); + /* Voice ↔ mascot link (level-driven, every frame). RECORDING + * → mic pose; TRANSCRIBING → ? pose; TYPING → no static pose + * (the per-glyph mascot_type_kick below drives the typing + * peck instead, same animation the soft keyboard uses). We + * must drive this every frame rather than on state edges, + * because voice_toggle() (IDLE→RECORDING) and begin_transcribe() + * (RECORDING→TRANSCRIBING) run inside the key-handler / + * voice_tick before we sample prev_state, so an edge-triggered + * link would sample prev==cur and skip the exact transition + * we need to catch. */ + int rec = (cur_state == VOICE_RECORDING); + int thk = (cur_state == VOICE_TRANSCRIBING); + if (rec) { mascot_set_recording(mc, 1); mascot_set_thinking(mc, 0); } + else if (thk) { mascot_set_thinking(mc, 1); mascot_set_recording(mc, 0); } + else { mascot_set_recording(mc, 0); mascot_set_thinking(mc, 0); } + + /* Voice TYPING streams glyphs straight to ssh_write inside + * voice.c, bypassing send_to_ssh — so the per-glyph typing + * kick wired into send_to_ssh wouldn't fire. Latch on the + * voice side, consume here and kick manually so the crab + * pecks in sync with each streamed character. */ + if (cur_state == VOICE_TYPING && voice_consume_typed(voice)) { + mascot_type_kick(mc); + } + /* ── SSH receive ── */ if (ssh && ssh_is_connected(ssh)) { ssh_keepalive_tick(ssh); @@ -365,16 +394,28 @@ int main(int argc, char *argv[]) { } } - /* ALERT = hard disconnect, OR active interactive stall. */ + /* ALERT = hard disconnect, OR active interactive stall. + * While voice input is active (RECORDING/TRANSCRIBING/TYPING) + * the user is talking to the mic, not typing, so the SSH + * channel going quiet is expected. Skip the stall detector + * entirely during voice so it can neither raise nor lower + * the alert — the crab is owned by the voice-state link + * above (mic / think poses). A real hard disconnect is + * still caught the moment voice goes idle again. */ time_t now_t = time(NULL); - int interactive_stall = - ssh && ssh_is_connected(ssh) && - g_last_tx_at > last_rx_at && - (now_t - g_last_tx_at) > STALL_TX_NORX_S; - int want_alert = ssh_dead || interactive_stall; - if (want_alert != stall_alert) { - stall_alert = want_alert; - mascot_set_alert(mc, stall_alert); + voice_state_t vs = voice_state(voice); + int voice_busy = (vs == VOICE_RECORDING || vs == VOICE_TRANSCRIBING || + vs == VOICE_TYPING); + if (!voice_busy) { + int interactive_stall = + ssh && ssh_is_connected(ssh) && + g_last_tx_at > last_rx_at && + (now_t - g_last_tx_at) > STALL_TX_NORX_S; + int want_alert = ssh_dead || interactive_stall; + if (want_alert != stall_alert) { + stall_alert = want_alert; + mascot_set_alert(mc, stall_alert); + } } /* ── SELECT reconnect (only when the session is dead) ── @@ -393,18 +434,53 @@ int main(int argc, char *argv[]) { int select_consumed = 0; if (ssh_dead && !modal_open && (down & KEY_SELECT)) { select_consumed = 1; - terminal_write(term, "\x1b[33mreconnecting...\x1b[0m\r\n"); + terminal_write(term, + "\x1b[33mReconnecting...\x1b[0m\r\n"); + /* Put the crab into its "looking up / waiting" pose before + * we flush the frame below, so the user sees it react. */ + mascot_set_reconnecting(mc, 1); + /* ssh_connect_pubkey below is a blocking handshake that + * takes a few seconds. The normal per-frame render at + * the bottom of this loop won't run until it returns, so + * the line above would stay hidden in the terminal buffer + * for the whole wait. Flush one frame right now so the + * user actually sees "Reconnecting..." while the handshake + * is in flight, rather than a frozen blank screen. */ + C3D_FrameBegin(C3D_FRAME_SYNCDRAW); + C2D_TargetClear(top, C2D_Color32(0x1a, 0x1b, 0x26, 0xff)); + C2D_SceneBegin(top); + renderer_draw_terminal(r, term); + C2D_TargetClear(bot, C2D_Color32(0x18, 0x18, 0x25, 0xff)); + C2D_SceneBegin(bot); + softkb_draw(kb, r, kbd); + /* Bottom row: clock + mascot, mirroring the main render + * path so the reconnect frame isn't missing anything. */ + if (!softkb_in_debug(kb)) { + char clock_buf[24]; + time_t now = time(NULL); + struct tm lt; + localtime_r(&now, <); + snprintf(clock_buf, sizeof(clock_buf), + "%02d-%02d %02d:%02d", + lt.tm_mon + 1, lt.tm_mday, + lt.tm_hour, lt.tm_min); + renderer_draw_text_px(2, 221, clock_buf, COLOR_DIM); + if (softkb_mascot_enabled(kb)) mascot_draw(mc); + } + C3D_FrameEnd(0); ssh = reconnect_ssh(&cfg, term, status_buf, sizeof(status_buf), err, sizeof(err)); + mascot_set_reconnecting(mc, 0); if (ssh) { ssh_dead = 0; stall_alert = 0; - mascot_set_alert(mc, 0); + mascot_celebrate(mc); last_rx_at = time(NULL); g_last_tx_at = last_rx_at; } else { + mascot_sadden(mc); terminal_write(term, - "\x1b[31mreconnect failed; press SELECT to retry.\x1b[0m\r\n"); + "\x1b[31mReconnect failed; press SELECT to retry.\x1b[0m\r\n"); } } @@ -421,7 +497,7 @@ int main(int argc, char *argv[]) { * the reconnect above, so it isn't emitted as Esc. */ if (ssh_dead || select_consumed) input_down &= ~KEY_SELECT; const char *out = keyboard_handle_input(kbd, term, input_down, held, cpad.dy); - if (out && !modal_open) send_to_ssh(ssh, term, out, (int)strlen(out)); + if (out && !modal_open) send_to_ssh(ssh, term, out, (int)strlen(out), mc); /* ── Touch / soft keyboard ── */ int touch_pressed = (held & KEY_TOUCH) ? 1 : 0; @@ -460,7 +536,7 @@ int main(int argc, char *argv[]) { * down-edge internally from prev_pressed. On no-touch * frames this also runs the release-fade path. */ const char *kt = softkb_touch(kb, kbd, tx, ty, touch_pressed); - if (kt) send_to_ssh(ssh, term, kt, (int)strlen(kt)); + if (kt) send_to_ssh(ssh, term, kt, (int)strlen(kt), mc); } /* Mascot ticks only when it's actually being shown. When